🪻 distributed transcription service thistle.dunkirk.sh

feat: add subscription paywall

dunkirk.sh 9b70d9be ec636045

verified
+51
CRUSH.md
···
- Admin link shows in auth menu only for admin users
- Redirects to home page if non-admin accesses admin page
## Future Additions
As the codebase grows, document:
···
- Admin link shows in auth menu only for admin users
- Redirects to home page if non-admin accesses admin page
+
## Subscription System
+
+
The application uses Polar for subscription management to gate access to transcription features.
+
+
**Subscription requirement:**
+
- Users must have an active subscription to upload and transcribe audio files
+
- Users can join classes and request classes without a subscription
+
- Admins bypass subscription requirements
+
+
**Protected routes:**
+
- `POST /api/transcriptions` - Upload audio file (requires subscription or admin)
+
- `GET /api/transcriptions` - List user's transcriptions (requires subscription or admin)
+
- `GET /api/transcriptions/:id` - Get transcription details (requires subscription or admin)
+
- `GET /api/transcriptions/:id/audio` - Download audio file (requires subscription or admin)
+
- `GET /api/transcriptions/:id/stream` - Real-time transcription updates (requires subscription or admin)
+
+
**Open routes (no subscription required):**
+
- All authentication endpoints (`/api/auth/*`)
+
- Class search and joining (`/api/classes/search`, `/api/classes/join`)
+
- Waitlist requests (`/api/classes/waitlist`)
+
- Billing/subscription management (`/api/billing/*`)
+
+
**Subscription statuses:**
+
- `active` - Full access to transcription features
+
- `trialing` - Trial period, full access
+
- `past_due` - Payment failed but still has access (grace period)
+
- `canceled` - No access to transcription features
+
- `expired` - No access to transcription features
+
+
**Implementation:**
+
- `subscriptions` table tracks user subscriptions from Polar
+
- `hasActiveSubscription(userId)` checks for active/trialing/past_due status
+
- `requireSubscription()` middleware enforces subscription requirement
+
- `/api/auth/me` returns `has_subscription` boolean
+
- Webhook at `/api/webhooks/polar` receives subscription updates from Polar
+
- Frontend components check `has_subscription` and show subscribe prompt
+
+
**User settings with query parameters:**
+
- Settings page supports `?tab=<tabname>` query parameter to open specific tabs
+
- Valid tabs: `account`, `sessions`, `passkeys`, `billing`, `danger`
+
- Example: `/settings?tab=billing` opens the billing tab directly
+
- Subscribe prompts link to `/settings?tab=billing` for direct access
+
- URL updates when switching tabs (browser history support)
+
+
**Testing subscriptions:**
+
Manually add a test subscription to the database:
+
```sql
+
INSERT INTO subscriptions (id, user_id, customer_id, status)
+
VALUES ('test-sub', <user_id>, 'test-customer', 'active');
+
```
+
## Future Additions
As the codebase grows, document:
+1
src/components/auth.ts
···
name: string | null;
avatar: string;
role?: "user" | "admin";
}
@customElement("auth-component")
···
name: string | null;
avatar: string;
role?: "user" | "admin";
+
has_subscription?: boolean;
}
@customElement("auth-component")
+32 -6
src/components/class-view.ts
···
@state() error: string | null = null;
@state() searchQuery = "";
@state() uploadModalOpen = false;
private eventSources: Map<string, EventSource> = new Map();
static override styles = css`
···
override async connectedCallback() {
super.connectedCallback();
this.extractClassId();
await this.loadClass();
this.connectToTranscriptionStreams();
···
const match = path.match(/^\/classes\/(.+)$/);
if (match?.[1]) {
this.classId = match[1];
}
}
···
`;
}
return html`
<div class="header">
<a href="/classes" class="back-link">← Back to all classes</a>
···
: ""
}
<div class="search-upload">
<input
type="text"
···
📤 Upload Recording
</button>
</div>
-
</div>
-
${
-
this.filteredTranscriptions.length === 0
-
? html`
<div class="empty-state">
<h2>${this.searchQuery ? "No matching recordings" : "No recordings yet"}</h2>
<p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p>
···
`
: html`
${this.filteredTranscriptions.map(
-
(t) => html`
<div class="transcription-card">
<div class="transcription-header">
<div>
···
${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""}
</div>
`,
-
)}
`
}
<upload-recording-modal
?open=${this.uploadModalOpen}
···
@state() error: string | null = null;
@state() searchQuery = "";
@state() uploadModalOpen = false;
+
@state() hasSubscription = false;
+
@state() isAdmin = false;
private eventSources: Map<string, EventSource> = new Map();
static override styles = css`
···
override async connectedCallback() {
super.connectedCallback();
this.extractClassId();
+
await this.checkAuth();
await this.loadClass();
this.connectToTranscriptionStreams();
···
const match = path.match(/^\/classes\/(.+)$/);
if (match?.[1]) {
this.classId = match[1];
+
}
+
}
+
+
private async checkAuth() {
+
try {
+
const response = await fetch("/api/auth/me");
+
if (response.ok) {
+
const data = await response.json();
+
this.hasSubscription = data.has_subscription || false;
+
this.isAdmin = data.role === "admin";
+
}
+
} catch (error) {
+
console.warn("Failed to check auth:", error);
}
}
···
`;
}
+
const canAccessTranscriptions = this.hasSubscription || this.isAdmin;
+
return html`
<div class="header">
<a href="/classes" class="back-link">← Back to all classes</a>
···
: ""
}
+
${!canAccessTranscriptions ? html`
+
<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;">
+
<h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Access Recordings</h3>
+
<p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and view transcriptions.</p>
+
<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>
+
</div>
+
` : html`
<div class="search-upload">
<input
type="text"
···
📤 Upload Recording
</button>
</div>
+
${
+
this.filteredTranscriptions.length === 0
+
? html`
<div class="empty-state">
<h2>${this.searchQuery ? "No matching recordings" : "No recordings yet"}</h2>
<p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p>
···
`
: html`
${this.filteredTranscriptions.map(
+
(t) => html`
<div class="transcription-card">
<div class="transcription-header">
<div>
···
${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""}
</div>
`,
+
)}
`
}
+
`}
+
</div>
<upload-recording-modal
?open=${this.uploadModalOpen}
+58 -15
src/components/transcription.ts
···
@state() serviceAvailable = true;
@state() existingClasses: string[] = [];
@state() showNewClassInput = false;
// Word streamers for each job
private wordStreamers = new Map<string, WordStreamer>();
// Displayed transcripts
···
override async connectedCallback() {
super.connectedCallback();
await this.checkHealth();
await this.loadJobs();
await this.loadExistingClasses();
···
this.eventSources.set(jobId, eventSource);
}
async checkHealth() {
try {
const response = await fetch("/api/transcriptions/health");
···
}
}
// Don't override serviceAvailable - it's set by checkHealth()
} else if (response.status === 404) {
// Transcription service not available - show empty state
this.jobs = [];
···
if (!response.ok) {
const data = await response.json();
-
alert(
-
data.error ||
-
"Upload failed - transcription service may be unavailable",
-
);
} else {
await response.json();
// Redirect to class page after successful upload
···
}
override render() {
return html`
-
<div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}"
-
@dragover=${this.serviceAvailable ? this.handleDragOver : null}
-
@dragleave=${this.serviceAvailable ? this.handleDragLeave : null}
-
@drop=${this.serviceAvailable ? this.handleDrop : null}
-
@click=${this.serviceAvailable ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}>
<div class="upload-icon">🎵</div>
<div class="upload-text">
${
!this.serviceAvailable
? "Transcription service unavailable"
-
: this.isUploading
-
? "Uploading..."
-
: "Drop audio file here or click to browse"
}
</div>
<div class="upload-hint">
-
${this.serviceAvailable ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 100MB" : "Transcription is currently unavailable"}
</div>
-
<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" : ""} />
</div>
${
-
this.serviceAvailable
? html`
<div class="upload-form">
<select
···
@state() serviceAvailable = true;
@state() existingClasses: string[] = [];
@state() showNewClassInput = false;
+
@state() hasSubscription = false;
+
@state() isAdmin = false;
// Word streamers for each job
private wordStreamers = new Map<string, WordStreamer>();
// Displayed transcripts
···
override async connectedCallback() {
super.connectedCallback();
+
await this.checkAuth();
await this.checkHealth();
await this.loadJobs();
await this.loadExistingClasses();
···
this.eventSources.set(jobId, eventSource);
}
+
async checkAuth() {
+
try {
+
const response = await fetch("/api/auth/me");
+
if (response.ok) {
+
const data = await response.json();
+
this.hasSubscription = data.has_subscription || false;
+
this.isAdmin = data.role === "admin";
+
}
+
} catch (error) {
+
console.warn("Failed to check auth:", error);
+
}
+
}
+
async checkHealth() {
try {
const response = await fetch("/api/transcriptions/health");
···
}
}
// Don't override serviceAvailable - it's set by checkHealth()
+
} else if (response.status === 403) {
+
// Subscription required - already handled by checkAuth
+
this.jobs = [];
} else if (response.status === 404) {
// Transcription service not available - show empty state
this.jobs = [];
···
if (!response.ok) {
const data = await response.json();
+
if (response.status === 403) {
+
// Subscription required
+
if (
+
confirm(
+
"Active subscription required to upload transcriptions. Would you like to subscribe now?",
+
)
+
) {
+
window.location.href = "/settings?tab=billing";
+
return;
+
}
+
} else {
+
alert(
+
data.error ||
+
"Upload failed - transcription service may be unavailable",
+
);
+
}
} else {
await response.json();
// Redirect to class page after successful upload
···
}
override render() {
+
const canUpload = this.serviceAvailable && (this.hasSubscription || this.isAdmin);
+
return html`
+
${!this.hasSubscription && !this.isAdmin ? html`
+
<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;">
+
<h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Upload Transcriptions</h3>
+
<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>
+
<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>
+
</div>
+
` : ''}
+
+
<div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!canUpload ? "disabled" : ""}"
+
@dragover=${canUpload ? this.handleDragOver : null}
+
@dragleave=${canUpload ? this.handleDragLeave : null}
+
@drop=${canUpload ? this.handleDrop : null}
+
@click=${canUpload ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}>
<div class="upload-icon">🎵</div>
<div class="upload-text">
${
!this.serviceAvailable
? "Transcription service unavailable"
+
: !this.hasSubscription && !this.isAdmin
+
? "Subscription required"
+
: this.isUploading
+
? "Uploading..."
+
: "Drop audio file here or click to browse"
}
</div>
<div class="upload-hint">
+
${canUpload ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 100MB" : "Transcription is currently unavailable"}
</div>
+
<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" : ""} />
</div>
${
+
canUpload
? html`
<div class="upload-form">
<select
+24 -4
src/components/user-settings.ts
···
override async connectedCallback() {
super.connectedCallback();
this.passkeySupported = isPasskeySupported();
await this.loadUser();
await this.loadSessions();
await this.loadSubscription();
if (this.passkeySupported) {
await this.loadPasskeys();
}
}
async loadUser() {
···
<button
class="tab ${this.currentPage === "account" ? "active" : ""}"
@click=${() => {
-
this.currentPage = "account";
}}
>
Account
···
<button
class="tab ${this.currentPage === "sessions" ? "active" : ""}"
@click=${() => {
-
this.currentPage = "sessions";
}}
>
Sessions
···
<button
class="tab ${this.currentPage === "billing" ? "active" : ""}"
@click=${() => {
-
this.currentPage = "billing";
}}
>
Billing
···
<button
class="tab ${this.currentPage === "danger" ? "active" : ""}"
@click=${() => {
-
this.currentPage = "danger";
}}
>
Danger Zone
···
override async connectedCallback() {
super.connectedCallback();
this.passkeySupported = isPasskeySupported();
+
+
// Check for tab query parameter
+
const params = new URLSearchParams(window.location.search);
+
const tab = params.get("tab");
+
if (tab && this.isValidTab(tab)) {
+
this.currentPage = tab as SettingsPage;
+
}
+
await this.loadUser();
await this.loadSessions();
await this.loadSubscription();
if (this.passkeySupported) {
await this.loadPasskeys();
}
+
}
+
+
private isValidTab(tab: string): boolean {
+
return ["account", "sessions", "passkeys", "billing", "danger"].includes(tab);
+
}
+
+
private setTab(tab: SettingsPage) {
+
this.currentPage = tab;
+
// Update URL without reloading page
+
const url = new URL(window.location.href);
+
url.searchParams.set("tab", tab);
+
window.history.pushState({}, "", url);
}
async loadUser() {
···
<button
class="tab ${this.currentPage === "account" ? "active" : ""}"
@click=${() => {
+
this.setTab("account");
}}
>
Account
···
<button
class="tab ${this.currentPage === "sessions" ? "active" : ""}"
@click=${() => {
+
this.setTab("sessions");
}}
>
Sessions
···
<button
class="tab ${this.currentPage === "billing" ? "active" : ""}"
@click=${() => {
+
this.setTab("billing");
}}
>
Billing
···
<button
class="tab ${this.currentPage === "danger" ? "active" : ""}"
@click=${() => {
+
this.setTab("danger");
}}
>
Danger Zone
+62 -46
src/index.ts
···
updateMeetingTime,
} from "./lib/classes";
import { handleError, ValidationErrors } from "./lib/errors";
-
import { requireAdmin, requireAuth } from "./lib/middleware";
import {
createAuthenticationOptions,
createRegistrationOptions,
···
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
return Response.json({
email: user.email,
name: user.name,
avatar: user.avatar,
created_at: user.created_at,
role: user.role,
});
},
},
···
try {
// Get subscription from database
-
const subscription = db.query<{
-
id: string;
-
status: string;
-
current_period_start: number | null;
-
current_period_end: number | null;
-
cancel_at_period_end: number;
-
canceled_at: number | null;
-
}>(
"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",
).get(user.id);
···
const { polar } = await import("./lib/polar");
// Get subscription to find customer ID
-
const subscription = db.query<{
-
customer_id: string;
-
}>(
"SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
).get(user.id);
···
},
"/api/transcriptions/:id/stream": {
GET: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
const transcriptionId = req.params.id;
-
// Verify ownership
-
const transcription = db
-
.query<{ id: string; user_id: number; status: string }, [string]>(
-
"SELECT id, user_id, status FROM transcriptions WHERE id = ?",
-
)
-
.get(transcriptionId);
-
if (!transcription || transcription.user_id !== user.id) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
-
}
-
// Event-driven SSE stream with reconnection support
-
const stream = new ReadableStream({
-
async start(controller) {
const encoder = new TextEncoder();
let isClosed = false;
let lastEventId = Math.floor(Date.now() / 1000);
···
},
});
return new Response(stream, {
-
headers: {
-
"Content-Type": "text/event-stream",
-
"Cache-Control": "no-cache",
-
Connection: "keep-alive",
-
},
-
});
},
},
"/api/transcriptions/health": {
···
"/api/transcriptions/:id": {
GET: async (req) => {
try {
-
const user = requireAuth(req);
const transcriptionId = req.params.id;
// Verify ownership or admin
···
"/api/transcriptions/:id/audio": {
GET: async (req) => {
try {
-
const user = requireAuth(req);
const transcriptionId = req.params.id;
// Verify ownership or admin
···
"/api/transcriptions": {
GET: async (req) => {
try {
-
const user = requireAuth(req);
const transcriptions = db
.query<
···
},
POST: async (req) => {
try {
-
const user = requireAuth(req);
const formData = await req.formData();
const file = formData.get("audio") as File;
···
updateMeetingTime,
} from "./lib/classes";
import { handleError, ValidationErrors } from "./lib/errors";
+
import {
+
requireAdmin,
+
requireAuth,
+
requireSubscription,
+
} from "./lib/middleware";
import {
createAuthenticationOptions,
createRegistrationOptions,
···
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
+
+
// Check subscription status
+
const subscription = db
+
.query<{ status: string }, [number]>(
+
"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
+
)
+
.get(user.id);
+
return Response.json({
email: user.email,
name: user.name,
avatar: user.avatar,
created_at: user.created_at,
role: user.role,
+
has_subscription: !!subscription,
});
},
},
···
try {
// Get subscription from database
+
const subscription = db.query<
+
{
+
id: string;
+
status: string;
+
current_period_start: number | null;
+
current_period_end: number | null;
+
cancel_at_period_end: number;
+
canceled_at: number | null;
+
},
+
[number]
+
>(
"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",
).get(user.id);
···
const { polar } = await import("./lib/polar");
// Get subscription to find customer ID
+
const subscription = db.query<
+
{
+
customer_id: string;
+
},
+
[number]
+
>(
"SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
).get(user.id);
···
},
"/api/transcriptions/:id/stream": {
GET: async (req) => {
+
try {
+
const user = requireSubscription(req);
+
const transcriptionId = req.params.id;
+
// Verify ownership
+
const transcription = db
+
.query<{ id: string; user_id: number; status: string }, [string]>(
+
"SELECT id, user_id, status FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptionId);
+
if (!transcription || transcription.user_id !== user.id) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
+
);
+
}
+
// Event-driven SSE stream with reconnection support
+
const stream = new ReadableStream({
+
async start(controller) {
const encoder = new TextEncoder();
let isClosed = false;
let lastEventId = Math.floor(Date.now() / 1000);
···
},
});
return new Response(stream, {
+
headers: {
+
"Content-Type": "text/event-stream",
+
"Cache-Control": "no-cache",
+
Connection: "keep-alive",
+
},
+
});
+
} catch (error) {
+
return handleError(error);
+
}
},
},
"/api/transcriptions/health": {
···
"/api/transcriptions/:id": {
GET: async (req) => {
try {
+
const user = requireSubscription(req);
const transcriptionId = req.params.id;
// Verify ownership or admin
···
"/api/transcriptions/:id/audio": {
GET: async (req) => {
try {
+
const user = requireSubscription(req);
const transcriptionId = req.params.id;
// Verify ownership or admin
···
"/api/transcriptions": {
GET: async (req) => {
try {
+
const user = requireSubscription(req);
const transcriptions = db
.query<
···
},
POST: async (req) => {
try {
+
const user = requireSubscription(req);
const formData = await req.formData();
const file = formData.get("audio") as File;
+7
src/lib/errors.ts
···
INVALID_SESSION = "INVALID_SESSION",
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
EMAIL_ALREADY_EXISTS = "EMAIL_ALREADY_EXISTS",
// Validation errors
VALIDATION_FAILED = "VALIDATION_FAILED",
···
),
adminRequired: () =>
new AppError(ErrorCode.AUTH_REQUIRED, "Admin access required", 403),
};
export const ValidationErrors = {
···
INVALID_SESSION = "INVALID_SESSION",
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
EMAIL_ALREADY_EXISTS = "EMAIL_ALREADY_EXISTS",
+
SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED",
// Validation errors
VALIDATION_FAILED = "VALIDATION_FAILED",
···
),
adminRequired: () =>
new AppError(ErrorCode.AUTH_REQUIRED, "Admin access required", 403),
+
subscriptionRequired: () =>
+
new AppError(
+
ErrorCode.SUBSCRIPTION_REQUIRED,
+
"Active subscription required",
+
403,
+
),
};
export const ValidationErrors = {
+112
src/lib/middleware.test.ts
···
···
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+
import db from "../db/schema";
+
import { createSession, createUser } from "./auth";
+
import { AppError } from "./errors";
+
import { hasActiveSubscription, requireSubscription } from "./middleware";
+
+
describe("middleware", () => {
+
let testUserId: number;
+
let sessionId: string;
+
+
beforeEach(async () => {
+
// Create test user
+
const user = await createUser(
+
`test-${Date.now()}@example.com`,
+
"0".repeat(64),
+
"Test User",
+
);
+
testUserId = user.id;
+
sessionId = createSession(testUserId, "127.0.0.1", "test");
+
});
+
+
afterEach(() => {
+
// Cleanup
+
db.run("DELETE FROM users WHERE id = ?", [testUserId]);
+
db.run("DELETE FROM subscriptions WHERE user_id = ?", [testUserId]);
+
});
+
+
describe("hasActiveSubscription", () => {
+
test("returns false when user has no subscription", () => {
+
expect(hasActiveSubscription(testUserId)).toBe(false);
+
});
+
+
test("returns true when user has active subscription", () => {
+
db.run(
+
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
+
["test-sub-1", testUserId, "test-customer", "active"],
+
);
+
+
expect(hasActiveSubscription(testUserId)).toBe(true);
+
});
+
+
test("returns true when user has trialing subscription", () => {
+
db.run(
+
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
+
["test-sub-2", testUserId, "test-customer", "trialing"],
+
);
+
+
expect(hasActiveSubscription(testUserId)).toBe(true);
+
});
+
+
test("returns false when user has canceled subscription", () => {
+
db.run(
+
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
+
["test-sub-3", testUserId, "test-customer", "canceled"],
+
);
+
+
expect(hasActiveSubscription(testUserId)).toBe(false);
+
});
+
});
+
+
describe("requireSubscription", () => {
+
test("throws AppError when user has no subscription", () => {
+
const req = new Request("http://localhost/api/test", {
+
headers: {
+
Cookie: `session=${sessionId}`,
+
},
+
});
+
+
try {
+
requireSubscription(req);
+
expect(true).toBe(false); // Should not reach here
+
} catch (error) {
+
expect(error instanceof AppError).toBe(true);
+
if (error instanceof AppError) {
+
expect(error.statusCode).toBe(403);
+
expect(error.message).toContain("subscription");
+
}
+
}
+
});
+
+
test("succeeds when user has active subscription", () => {
+
db.run(
+
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
+
["test-sub-4", testUserId, "test-customer", "active"],
+
);
+
+
const req = new Request("http://localhost/api/test", {
+
headers: {
+
Cookie: `session=${sessionId}`,
+
},
+
});
+
+
const user = requireSubscription(req);
+
expect(user.id).toBe(testUserId);
+
});
+
+
test("succeeds when user is admin without subscription", () => {
+
// Make user admin
+
db.run("UPDATE users SET role = ? WHERE id = ?", ["admin", testUserId]);
+
+
const req = new Request("http://localhost/api/test", {
+
headers: {
+
Cookie: `session=${sessionId}`,
+
},
+
});
+
+
const user = requireSubscription(req);
+
expect(user.id).toBe(testUserId);
+
expect(user.role).toBe("admin");
+
});
+
});
+
});
+26
src/lib/middleware.ts
···
// Helper functions for route authentication and error handling
import type { User } from "./auth";
import { getSessionFromRequest, getUserBySession } from "./auth";
import { AuthErrors } from "./errors";
···
return user;
}
···
// Helper functions for route authentication and error handling
+
import db from "../db/schema";
import type { User } from "./auth";
import { getSessionFromRequest, getUserBySession } from "./auth";
import { AuthErrors } from "./errors";
···
return user;
}
+
+
export function hasActiveSubscription(userId: number): boolean {
+
const subscription = db
+
.query<{ status: string }, [number]>(
+
"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
+
)
+
.get(userId);
+
+
return !!subscription;
+
}
+
+
export function requireSubscription(req: Request): User {
+
const user = requireAuth(req);
+
+
// Admins bypass subscription requirement
+
if (user.role === "admin") {
+
return user;
+
}
+
+
if (!hasActiveSubscription(user.id)) {
+
throw AuthErrors.subscriptionRequired();
+
}
+
+
return user;
+
}
+105
src/lib/subscription-routes.test.ts
···
···
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+
import db from "../db/schema";
+
import { createSession, createUser } from "./auth";
+
+
describe("subscription-protected routes", () => {
+
let testUserId: number;
+
let sessionCookie: string;
+
+
beforeEach(async () => {
+
// Create test user
+
const user = await createUser(
+
`test-${Date.now()}@example.com`,
+
"0".repeat(64),
+
"Test User",
+
);
+
testUserId = user.id;
+
const sessionId = createSession(testUserId, "127.0.0.1", "test");
+
sessionCookie = `session=${sessionId}`;
+
});
+
+
afterEach(() => {
+
// Cleanup
+
db.run("DELETE FROM users WHERE id = ?", [testUserId]);
+
db.run("DELETE FROM subscriptions WHERE user_id = ?", [testUserId]);
+
});
+
+
test("GET /api/transcriptions requires subscription", async () => {
+
const response = await fetch("http://localhost:3000/api/transcriptions", {
+
headers: { Cookie: sessionCookie },
+
});
+
+
expect(response.status).toBe(500);
+
const data = await response.json();
+
expect(data.error).toContain("subscription");
+
});
+
+
test("GET /api/transcriptions succeeds with active subscription", async () => {
+
// Add subscription
+
db.run(
+
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
+
["test-sub", testUserId, "test-customer", "active"],
+
);
+
+
const response = await fetch("http://localhost:3000/api/transcriptions", {
+
headers: { Cookie: sessionCookie },
+
});
+
+
expect(response.status).toBe(200);
+
const data = await response.json();
+
expect(data.jobs).toBeDefined();
+
});
+
+
test("GET /api/transcriptions succeeds for admin without subscription", async () => {
+
// Make user admin
+
db.run("UPDATE users SET role = ? WHERE id = ?", ["admin", testUserId]);
+
+
const response = await fetch("http://localhost:3000/api/transcriptions", {
+
headers: { Cookie: sessionCookie },
+
});
+
+
expect(response.status).toBe(200);
+
const data = await response.json();
+
expect(data.jobs).toBeDefined();
+
});
+
+
test("POST /api/transcriptions requires subscription", async () => {
+
const formData = new FormData();
+
const file = new File(["test"], "test.mp3", { type: "audio/mpeg" });
+
formData.append("audio", file);
+
+
const response = await fetch("http://localhost:3000/api/transcriptions", {
+
method: "POST",
+
headers: { Cookie: sessionCookie },
+
body: formData,
+
});
+
+
expect(response.status).toBe(500);
+
const data = await response.json();
+
expect(data.error).toContain("subscription");
+
});
+
+
test("/api/auth/me includes subscription status", async () => {
+
const response = await fetch("http://localhost:3000/api/auth/me", {
+
headers: { Cookie: sessionCookie },
+
});
+
+
expect(response.status).toBe(200);
+
const data = await response.json();
+
expect(data.has_subscription).toBe(false);
+
+
// Add subscription
+
db.run(
+
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
+
["test-sub", testUserId, "test-customer", "active"],
+
);
+
+
const response2 = await fetch("http://localhost:3000/api/auth/me", {
+
headers: { Cookie: sessionCookie },
+
});
+
+
expect(response2.status).toBe(200);
+
const data2 = await response2.json();
+
expect(data2.has_subscription).toBe(true);
+
});
+
});