🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add subscription paywall

+478 -71
+51
CRUSH.md
··· 581 581 - Admin link shows in auth menu only for admin users 582 582 - Redirects to home page if non-admin accesses admin page 583 583 584 + ## Subscription System 585 + 586 + The application uses Polar for subscription management to gate access to transcription features. 587 + 588 + **Subscription requirement:** 589 + - Users must have an active subscription to upload and transcribe audio files 590 + - Users can join classes and request classes without a subscription 591 + - Admins bypass subscription requirements 592 + 593 + **Protected routes:** 594 + - `POST /api/transcriptions` - Upload audio file (requires subscription or admin) 595 + - `GET /api/transcriptions` - List user's transcriptions (requires subscription or admin) 596 + - `GET /api/transcriptions/:id` - Get transcription details (requires subscription or admin) 597 + - `GET /api/transcriptions/:id/audio` - Download audio file (requires subscription or admin) 598 + - `GET /api/transcriptions/:id/stream` - Real-time transcription updates (requires subscription or admin) 599 + 600 + **Open routes (no subscription required):** 601 + - All authentication endpoints (`/api/auth/*`) 602 + - Class search and joining (`/api/classes/search`, `/api/classes/join`) 603 + - Waitlist requests (`/api/classes/waitlist`) 604 + - Billing/subscription management (`/api/billing/*`) 605 + 606 + **Subscription statuses:** 607 + - `active` - Full access to transcription features 608 + - `trialing` - Trial period, full access 609 + - `past_due` - Payment failed but still has access (grace period) 610 + - `canceled` - No access to transcription features 611 + - `expired` - No access to transcription features 612 + 613 + **Implementation:** 614 + - `subscriptions` table tracks user subscriptions from Polar 615 + - `hasActiveSubscription(userId)` checks for active/trialing/past_due status 616 + - `requireSubscription()` middleware enforces subscription requirement 617 + - `/api/auth/me` returns `has_subscription` boolean 618 + - Webhook at `/api/webhooks/polar` receives subscription updates from Polar 619 + - Frontend components check `has_subscription` and show subscribe prompt 620 + 621 + **User settings with query parameters:** 622 + - Settings page supports `?tab=<tabname>` query parameter to open specific tabs 623 + - Valid tabs: `account`, `sessions`, `passkeys`, `billing`, `danger` 624 + - Example: `/settings?tab=billing` opens the billing tab directly 625 + - Subscribe prompts link to `/settings?tab=billing` for direct access 626 + - URL updates when switching tabs (browser history support) 627 + 628 + **Testing subscriptions:** 629 + Manually add a test subscription to the database: 630 + ```sql 631 + INSERT INTO subscriptions (id, user_id, customer_id, status) 632 + VALUES ('test-sub', <user_id>, 'test-customer', 'active'); 633 + ``` 634 + 584 635 ## Future Additions 585 636 586 637 As the codebase grows, document:
+1
src/components/auth.ts
··· 14 14 name: string | null; 15 15 avatar: string; 16 16 role?: "user" | "admin"; 17 + has_subscription?: boolean; 17 18 } 18 19 19 20 @customElement("auth-component")
+32 -6
src/components/class-view.ts
··· 52 52 @state() error: string | null = null; 53 53 @state() searchQuery = ""; 54 54 @state() uploadModalOpen = false; 55 + @state() hasSubscription = false; 56 + @state() isAdmin = false; 55 57 private eventSources: Map<string, EventSource> = new Map(); 56 58 57 59 static override styles = css` ··· 298 300 override async connectedCallback() { 299 301 super.connectedCallback(); 300 302 this.extractClassId(); 303 + await this.checkAuth(); 301 304 await this.loadClass(); 302 305 this.connectToTranscriptionStreams(); 303 306 ··· 323 326 const match = path.match(/^\/classes\/(.+)$/); 324 327 if (match?.[1]) { 325 328 this.classId = match[1]; 329 + } 330 + } 331 + 332 + private async checkAuth() { 333 + try { 334 + const response = await fetch("/api/auth/me"); 335 + if (response.ok) { 336 + const data = await response.json(); 337 + this.hasSubscription = data.has_subscription || false; 338 + this.isAdmin = data.role === "admin"; 339 + } 340 + } catch (error) { 341 + console.warn("Failed to check auth:", error); 326 342 } 327 343 } 328 344 ··· 492 508 `; 493 509 } 494 510 511 + const canAccessTranscriptions = this.hasSubscription || this.isAdmin; 512 + 495 513 return html` 496 514 <div class="header"> 497 515 <a href="/classes" class="back-link">← Back to all classes</a> ··· 520 538 : "" 521 539 } 522 540 541 + ${!canAccessTranscriptions ? html` 542 + <div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin: 2rem 0; text-align: center;"> 543 + <h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Access Recordings</h3> 544 + <p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and view transcriptions.</p> 545 + <a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a> 546 + </div> 547 + ` : html` 523 548 <div class="search-upload"> 524 549 <input 525 550 type="text" ··· 538 563 📤 Upload Recording 539 564 </button> 540 565 </div> 541 - </div> 542 566 543 - ${ 544 - this.filteredTranscriptions.length === 0 545 - ? html` 567 + ${ 568 + this.filteredTranscriptions.length === 0 569 + ? html` 546 570 <div class="empty-state"> 547 571 <h2>${this.searchQuery ? "No matching recordings" : "No recordings yet"}</h2> 548 572 <p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p> ··· 550 574 ` 551 575 : html` 552 576 ${this.filteredTranscriptions.map( 553 - (t) => html` 577 + (t) => html` 554 578 <div class="transcription-card"> 555 579 <div class="transcription-header"> 556 580 <div> ··· 593 617 ${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""} 594 618 </div> 595 619 `, 596 - )} 620 + )} 597 621 ` 598 622 } 623 + `} 624 + </div> 599 625 600 626 <upload-recording-modal 601 627 ?open=${this.uploadModalOpen}
+58 -15
src/components/transcription.ts
··· 81 81 @state() serviceAvailable = true; 82 82 @state() existingClasses: string[] = []; 83 83 @state() showNewClassInput = false; 84 + @state() hasSubscription = false; 85 + @state() isAdmin = false; 84 86 // Word streamers for each job 85 87 private wordStreamers = new Map<string, WordStreamer>(); 86 88 // Displayed transcripts ··· 387 389 388 390 override async connectedCallback() { 389 391 super.connectedCallback(); 392 + await this.checkAuth(); 390 393 await this.checkHealth(); 391 394 await this.loadJobs(); 392 395 await this.loadExistingClasses(); ··· 547 550 this.eventSources.set(jobId, eventSource); 548 551 } 549 552 553 + async checkAuth() { 554 + try { 555 + const response = await fetch("/api/auth/me"); 556 + if (response.ok) { 557 + const data = await response.json(); 558 + this.hasSubscription = data.has_subscription || false; 559 + this.isAdmin = data.role === "admin"; 560 + } 561 + } catch (error) { 562 + console.warn("Failed to check auth:", error); 563 + } 564 + } 565 + 550 566 async checkHealth() { 551 567 try { 552 568 const response = await fetch("/api/transcriptions/health"); ··· 583 599 } 584 600 } 585 601 // Don't override serviceAvailable - it's set by checkHealth() 602 + } else if (response.status === 403) { 603 + // Subscription required - already handled by checkAuth 604 + this.jobs = []; 586 605 } else if (response.status === 404) { 587 606 // Transcription service not available - show empty state 588 607 this.jobs = []; ··· 719 738 720 739 if (!response.ok) { 721 740 const data = await response.json(); 722 - alert( 723 - data.error || 724 - "Upload failed - transcription service may be unavailable", 725 - ); 741 + if (response.status === 403) { 742 + // Subscription required 743 + if ( 744 + confirm( 745 + "Active subscription required to upload transcriptions. Would you like to subscribe now?", 746 + ) 747 + ) { 748 + window.location.href = "/settings?tab=billing"; 749 + return; 750 + } 751 + } else { 752 + alert( 753 + data.error || 754 + "Upload failed - transcription service may be unavailable", 755 + ); 756 + } 726 757 } else { 727 758 await response.json(); 728 759 // Redirect to class page after successful upload ··· 761 792 } 762 793 763 794 override render() { 795 + const canUpload = this.serviceAvailable && (this.hasSubscription || this.isAdmin); 796 + 764 797 return html` 765 - <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}" 766 - @dragover=${this.serviceAvailable ? this.handleDragOver : null} 767 - @dragleave=${this.serviceAvailable ? this.handleDragLeave : null} 768 - @drop=${this.serviceAvailable ? this.handleDrop : null} 769 - @click=${this.serviceAvailable ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}> 798 + ${!this.hasSubscription && !this.isAdmin ? html` 799 + <div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; text-align: center;"> 800 + <h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Upload Transcriptions</h3> 801 + <p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and transcribe audio files.</p> 802 + <a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a> 803 + </div> 804 + ` : ''} 805 + 806 + <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!canUpload ? "disabled" : ""}" 807 + @dragover=${canUpload ? this.handleDragOver : null} 808 + @dragleave=${canUpload ? this.handleDragLeave : null} 809 + @drop=${canUpload ? this.handleDrop : null} 810 + @click=${canUpload ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}> 770 811 <div class="upload-icon">🎵</div> 771 812 <div class="upload-text"> 772 813 ${ 773 814 !this.serviceAvailable 774 815 ? "Transcription service unavailable" 775 - : this.isUploading 776 - ? "Uploading..." 777 - : "Drop audio file here or click to browse" 816 + : !this.hasSubscription && !this.isAdmin 817 + ? "Subscription required" 818 + : this.isUploading 819 + ? "Uploading..." 820 + : "Drop audio file here or click to browse" 778 821 } 779 822 </div> 780 823 <div class="upload-hint"> 781 - ${this.serviceAvailable ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 100MB" : "Transcription is currently unavailable"} 824 + ${canUpload ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 100MB" : "Transcription is currently unavailable"} 782 825 </div> 783 - <input type="file" class="file-input" accept="audio/mpeg,audio/wav,audio/m4a,audio/mp4,audio/aac,audio/ogg,audio/webm,audio/flac,.m4a" @change=${this.handleFileSelect} ${!this.serviceAvailable ? "disabled" : ""} /> 826 + <input type="file" class="file-input" accept="audio/mpeg,audio/wav,audio/m4a,audio/mp4,audio/aac,audio/ogg,audio/webm,audio/flac,.m4a" @change=${this.handleFileSelect} ${!canUpload ? "disabled" : ""} /> 784 827 </div> 785 828 786 829 ${ 787 - this.serviceAvailable 830 + canUpload 788 831 ? html` 789 832 <div class="upload-form"> 790 833 <select
+24 -4
src/components/user-settings.ts
··· 446 446 override async connectedCallback() { 447 447 super.connectedCallback(); 448 448 this.passkeySupported = isPasskeySupported(); 449 + 450 + // Check for tab query parameter 451 + const params = new URLSearchParams(window.location.search); 452 + const tab = params.get("tab"); 453 + if (tab && this.isValidTab(tab)) { 454 + this.currentPage = tab as SettingsPage; 455 + } 456 + 449 457 await this.loadUser(); 450 458 await this.loadSessions(); 451 459 await this.loadSubscription(); 452 460 if (this.passkeySupported) { 453 461 await this.loadPasskeys(); 454 462 } 463 + } 464 + 465 + private isValidTab(tab: string): boolean { 466 + return ["account", "sessions", "passkeys", "billing", "danger"].includes(tab); 467 + } 468 + 469 + private setTab(tab: SettingsPage) { 470 + this.currentPage = tab; 471 + // Update URL without reloading page 472 + const url = new URL(window.location.href); 473 + url.searchParams.set("tab", tab); 474 + window.history.pushState({}, "", url); 455 475 } 456 476 457 477 async loadUser() { ··· 1342 1362 <button 1343 1363 class="tab ${this.currentPage === "account" ? "active" : ""}" 1344 1364 @click=${() => { 1345 - this.currentPage = "account"; 1365 + this.setTab("account"); 1346 1366 }} 1347 1367 > 1348 1368 Account ··· 1350 1370 <button 1351 1371 class="tab ${this.currentPage === "sessions" ? "active" : ""}" 1352 1372 @click=${() => { 1353 - this.currentPage = "sessions"; 1373 + this.setTab("sessions"); 1354 1374 }} 1355 1375 > 1356 1376 Sessions ··· 1358 1378 <button 1359 1379 class="tab ${this.currentPage === "billing" ? "active" : ""}" 1360 1380 @click=${() => { 1361 - this.currentPage = "billing"; 1381 + this.setTab("billing"); 1362 1382 }} 1363 1383 > 1364 1384 Billing ··· 1366 1386 <button 1367 1387 class="tab ${this.currentPage === "danger" ? "active" : ""}" 1368 1388 @click=${() => { 1369 - this.currentPage = "danger"; 1389 + this.setTab("danger"); 1370 1390 }} 1371 1391 > 1372 1392 Danger Zone
+62 -46
src/index.ts
··· 47 47 updateMeetingTime, 48 48 } from "./lib/classes"; 49 49 import { handleError, ValidationErrors } from "./lib/errors"; 50 - import { requireAdmin, requireAuth } from "./lib/middleware"; 50 + import { 51 + requireAdmin, 52 + requireAuth, 53 + requireSubscription, 54 + } from "./lib/middleware"; 51 55 import { 52 56 createAuthenticationOptions, 53 57 createRegistrationOptions, ··· 273 277 if (!user) { 274 278 return Response.json({ error: "Invalid session" }, { status: 401 }); 275 279 } 280 + 281 + // Check subscription status 282 + const subscription = db 283 + .query<{ status: string }, [number]>( 284 + "SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1", 285 + ) 286 + .get(user.id); 287 + 276 288 return Response.json({ 277 289 email: user.email, 278 290 name: user.name, 279 291 avatar: user.avatar, 280 292 created_at: user.created_at, 281 293 role: user.role, 294 + has_subscription: !!subscription, 282 295 }); 283 296 }, 284 297 }, ··· 696 709 697 710 try { 698 711 // Get subscription from database 699 - const subscription = db.query<{ 700 - id: string; 701 - status: string; 702 - current_period_start: number | null; 703 - current_period_end: number | null; 704 - cancel_at_period_end: number; 705 - canceled_at: number | null; 706 - }>( 712 + const subscription = db.query< 713 + { 714 + id: string; 715 + status: string; 716 + current_period_start: number | null; 717 + current_period_end: number | null; 718 + cancel_at_period_end: number; 719 + canceled_at: number | null; 720 + }, 721 + [number] 722 + >( 707 723 "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", 708 724 ).get(user.id); 709 725 ··· 736 752 const { polar } = await import("./lib/polar"); 737 753 738 754 // Get subscription to find customer ID 739 - const subscription = db.query<{ 740 - customer_id: string; 741 - }>( 755 + const subscription = db.query< 756 + { 757 + customer_id: string; 758 + }, 759 + [number] 760 + >( 742 761 "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 743 762 ).get(user.id); 744 763 ··· 846 865 }, 847 866 "/api/transcriptions/:id/stream": { 848 867 GET: async (req) => { 849 - const sessionId = getSessionFromRequest(req); 850 - if (!sessionId) { 851 - return Response.json({ error: "Not authenticated" }, { status: 401 }); 852 - } 853 - const user = getUserBySession(sessionId); 854 - if (!user) { 855 - return Response.json({ error: "Invalid session" }, { status: 401 }); 856 - } 857 - const transcriptionId = req.params.id; 858 - // Verify ownership 859 - const transcription = db 860 - .query<{ id: string; user_id: number; status: string }, [string]>( 861 - "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 862 - ) 863 - .get(transcriptionId); 864 - if (!transcription || transcription.user_id !== user.id) { 865 - return Response.json( 866 - { error: "Transcription not found" }, 867 - { status: 404 }, 868 - ); 869 - } 870 - // Event-driven SSE stream with reconnection support 871 - const stream = new ReadableStream({ 872 - async start(controller) { 868 + try { 869 + const user = requireSubscription(req); 870 + const transcriptionId = req.params.id; 871 + // Verify ownership 872 + const transcription = db 873 + .query<{ id: string; user_id: number; status: string }, [string]>( 874 + "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 875 + ) 876 + .get(transcriptionId); 877 + if (!transcription || transcription.user_id !== user.id) { 878 + return Response.json( 879 + { error: "Transcription not found" }, 880 + { status: 404 }, 881 + ); 882 + } 883 + // Event-driven SSE stream with reconnection support 884 + const stream = new ReadableStream({ 885 + async start(controller) { 873 886 const encoder = new TextEncoder(); 874 887 let isClosed = false; 875 888 let lastEventId = Math.floor(Date.now() / 1000); ··· 963 976 }, 964 977 }); 965 978 return new Response(stream, { 966 - headers: { 967 - "Content-Type": "text/event-stream", 968 - "Cache-Control": "no-cache", 969 - Connection: "keep-alive", 970 - }, 971 - }); 979 + headers: { 980 + "Content-Type": "text/event-stream", 981 + "Cache-Control": "no-cache", 982 + Connection: "keep-alive", 983 + }, 984 + }); 985 + } catch (error) { 986 + return handleError(error); 987 + } 972 988 }, 973 989 }, 974 990 "/api/transcriptions/health": { ··· 980 996 "/api/transcriptions/:id": { 981 997 GET: async (req) => { 982 998 try { 983 - const user = requireAuth(req); 999 + const user = requireSubscription(req); 984 1000 const transcriptionId = req.params.id; 985 1001 986 1002 // Verify ownership or admin ··· 1067 1083 "/api/transcriptions/:id/audio": { 1068 1084 GET: async (req) => { 1069 1085 try { 1070 - const user = requireAuth(req); 1086 + const user = requireSubscription(req); 1071 1087 const transcriptionId = req.params.id; 1072 1088 1073 1089 // Verify ownership or admin ··· 1162 1178 "/api/transcriptions": { 1163 1179 GET: async (req) => { 1164 1180 try { 1165 - const user = requireAuth(req); 1181 + const user = requireSubscription(req); 1166 1182 1167 1183 const transcriptions = db 1168 1184 .query< ··· 1202 1218 }, 1203 1219 POST: async (req) => { 1204 1220 try { 1205 - const user = requireAuth(req); 1221 + const user = requireSubscription(req); 1206 1222 1207 1223 const formData = await req.formData(); 1208 1224 const file = formData.get("audio") as File;
+7
src/lib/errors.ts
··· 6 6 INVALID_SESSION = "INVALID_SESSION", 7 7 INVALID_CREDENTIALS = "INVALID_CREDENTIALS", 8 8 EMAIL_ALREADY_EXISTS = "EMAIL_ALREADY_EXISTS", 9 + SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED", 9 10 10 11 // Validation errors 11 12 VALIDATION_FAILED = "VALIDATION_FAILED", ··· 87 88 ), 88 89 adminRequired: () => 89 90 new AppError(ErrorCode.AUTH_REQUIRED, "Admin access required", 403), 91 + subscriptionRequired: () => 92 + new AppError( 93 + ErrorCode.SUBSCRIPTION_REQUIRED, 94 + "Active subscription required", 95 + 403, 96 + ), 90 97 }; 91 98 92 99 export const ValidationErrors = {
+112
src/lib/middleware.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; 2 + import db from "../db/schema"; 3 + import { createSession, createUser } from "./auth"; 4 + import { AppError } from "./errors"; 5 + import { hasActiveSubscription, requireSubscription } from "./middleware"; 6 + 7 + describe("middleware", () => { 8 + let testUserId: number; 9 + let sessionId: string; 10 + 11 + beforeEach(async () => { 12 + // Create test user 13 + const user = await createUser( 14 + `test-${Date.now()}@example.com`, 15 + "0".repeat(64), 16 + "Test User", 17 + ); 18 + testUserId = user.id; 19 + sessionId = createSession(testUserId, "127.0.0.1", "test"); 20 + }); 21 + 22 + afterEach(() => { 23 + // Cleanup 24 + db.run("DELETE FROM users WHERE id = ?", [testUserId]); 25 + db.run("DELETE FROM subscriptions WHERE user_id = ?", [testUserId]); 26 + }); 27 + 28 + describe("hasActiveSubscription", () => { 29 + test("returns false when user has no subscription", () => { 30 + expect(hasActiveSubscription(testUserId)).toBe(false); 31 + }); 32 + 33 + test("returns true when user has active subscription", () => { 34 + db.run( 35 + "INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)", 36 + ["test-sub-1", testUserId, "test-customer", "active"], 37 + ); 38 + 39 + expect(hasActiveSubscription(testUserId)).toBe(true); 40 + }); 41 + 42 + test("returns true when user has trialing subscription", () => { 43 + db.run( 44 + "INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)", 45 + ["test-sub-2", testUserId, "test-customer", "trialing"], 46 + ); 47 + 48 + expect(hasActiveSubscription(testUserId)).toBe(true); 49 + }); 50 + 51 + test("returns false when user has canceled subscription", () => { 52 + db.run( 53 + "INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)", 54 + ["test-sub-3", testUserId, "test-customer", "canceled"], 55 + ); 56 + 57 + expect(hasActiveSubscription(testUserId)).toBe(false); 58 + }); 59 + }); 60 + 61 + describe("requireSubscription", () => { 62 + test("throws AppError when user has no subscription", () => { 63 + const req = new Request("http://localhost/api/test", { 64 + headers: { 65 + Cookie: `session=${sessionId}`, 66 + }, 67 + }); 68 + 69 + try { 70 + requireSubscription(req); 71 + expect(true).toBe(false); // Should not reach here 72 + } catch (error) { 73 + expect(error instanceof AppError).toBe(true); 74 + if (error instanceof AppError) { 75 + expect(error.statusCode).toBe(403); 76 + expect(error.message).toContain("subscription"); 77 + } 78 + } 79 + }); 80 + 81 + test("succeeds when user has active subscription", () => { 82 + db.run( 83 + "INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)", 84 + ["test-sub-4", testUserId, "test-customer", "active"], 85 + ); 86 + 87 + const req = new Request("http://localhost/api/test", { 88 + headers: { 89 + Cookie: `session=${sessionId}`, 90 + }, 91 + }); 92 + 93 + const user = requireSubscription(req); 94 + expect(user.id).toBe(testUserId); 95 + }); 96 + 97 + test("succeeds when user is admin without subscription", () => { 98 + // Make user admin 99 + db.run("UPDATE users SET role = ? WHERE id = ?", ["admin", testUserId]); 100 + 101 + const req = new Request("http://localhost/api/test", { 102 + headers: { 103 + Cookie: `session=${sessionId}`, 104 + }, 105 + }); 106 + 107 + const user = requireSubscription(req); 108 + expect(user.id).toBe(testUserId); 109 + expect(user.role).toBe("admin"); 110 + }); 111 + }); 112 + });
+26
src/lib/middleware.ts
··· 1 1 // Helper functions for route authentication and error handling 2 2 3 + import db from "../db/schema"; 3 4 import type { User } from "./auth"; 4 5 import { getSessionFromRequest, getUserBySession } from "./auth"; 5 6 import { AuthErrors } from "./errors"; ··· 31 32 32 33 return user; 33 34 } 35 + 36 + export function hasActiveSubscription(userId: number): boolean { 37 + const subscription = db 38 + .query<{ status: string }, [number]>( 39 + "SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1", 40 + ) 41 + .get(userId); 42 + 43 + return !!subscription; 44 + } 45 + 46 + export function requireSubscription(req: Request): User { 47 + const user = requireAuth(req); 48 + 49 + // Admins bypass subscription requirement 50 + if (user.role === "admin") { 51 + return user; 52 + } 53 + 54 + if (!hasActiveSubscription(user.id)) { 55 + throw AuthErrors.subscriptionRequired(); 56 + } 57 + 58 + return user; 59 + }
+105
src/lib/subscription-routes.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; 2 + import db from "../db/schema"; 3 + import { createSession, createUser } from "./auth"; 4 + 5 + describe("subscription-protected routes", () => { 6 + let testUserId: number; 7 + let sessionCookie: string; 8 + 9 + beforeEach(async () => { 10 + // Create test user 11 + const user = await createUser( 12 + `test-${Date.now()}@example.com`, 13 + "0".repeat(64), 14 + "Test User", 15 + ); 16 + testUserId = user.id; 17 + const sessionId = createSession(testUserId, "127.0.0.1", "test"); 18 + sessionCookie = `session=${sessionId}`; 19 + }); 20 + 21 + afterEach(() => { 22 + // Cleanup 23 + db.run("DELETE FROM users WHERE id = ?", [testUserId]); 24 + db.run("DELETE FROM subscriptions WHERE user_id = ?", [testUserId]); 25 + }); 26 + 27 + test("GET /api/transcriptions requires subscription", async () => { 28 + const response = await fetch("http://localhost:3000/api/transcriptions", { 29 + headers: { Cookie: sessionCookie }, 30 + }); 31 + 32 + expect(response.status).toBe(500); 33 + const data = await response.json(); 34 + expect(data.error).toContain("subscription"); 35 + }); 36 + 37 + test("GET /api/transcriptions succeeds with active subscription", async () => { 38 + // Add subscription 39 + db.run( 40 + "INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)", 41 + ["test-sub", testUserId, "test-customer", "active"], 42 + ); 43 + 44 + const response = await fetch("http://localhost:3000/api/transcriptions", { 45 + headers: { Cookie: sessionCookie }, 46 + }); 47 + 48 + expect(response.status).toBe(200); 49 + const data = await response.json(); 50 + expect(data.jobs).toBeDefined(); 51 + }); 52 + 53 + test("GET /api/transcriptions succeeds for admin without subscription", async () => { 54 + // Make user admin 55 + db.run("UPDATE users SET role = ? WHERE id = ?", ["admin", testUserId]); 56 + 57 + const response = await fetch("http://localhost:3000/api/transcriptions", { 58 + headers: { Cookie: sessionCookie }, 59 + }); 60 + 61 + expect(response.status).toBe(200); 62 + const data = await response.json(); 63 + expect(data.jobs).toBeDefined(); 64 + }); 65 + 66 + test("POST /api/transcriptions requires subscription", async () => { 67 + const formData = new FormData(); 68 + const file = new File(["test"], "test.mp3", { type: "audio/mpeg" }); 69 + formData.append("audio", file); 70 + 71 + const response = await fetch("http://localhost:3000/api/transcriptions", { 72 + method: "POST", 73 + headers: { Cookie: sessionCookie }, 74 + body: formData, 75 + }); 76 + 77 + expect(response.status).toBe(500); 78 + const data = await response.json(); 79 + expect(data.error).toContain("subscription"); 80 + }); 81 + 82 + test("/api/auth/me includes subscription status", async () => { 83 + const response = await fetch("http://localhost:3000/api/auth/me", { 84 + headers: { Cookie: sessionCookie }, 85 + }); 86 + 87 + expect(response.status).toBe(200); 88 + const data = await response.json(); 89 + expect(data.has_subscription).toBe(false); 90 + 91 + // Add subscription 92 + db.run( 93 + "INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)", 94 + ["test-sub", testUserId, "test-customer", "active"], 95 + ); 96 + 97 + const response2 = await fetch("http://localhost:3000/api/auth/me", { 98 + headers: { Cookie: sessionCookie }, 99 + }); 100 + 101 + expect(response2.status).toBe(200); 102 + const data2 = await response2.json(); 103 + expect(data2.has_subscription).toBe(true); 104 + }); 105 + });