🪻 distributed transcription service thistle.dunkirk.sh

chore: biome format

dunkirk.sh 0e3ccd2d c734cd5f

verified
+28 -28
package.json
···
{
-
"name": "thistle",
-
"module": "src/index.ts",
-
"type": "module",
-
"private": true,
-
"scripts": {
-
"dev": "bun run src/index.ts --hot",
-
"clean": "rm -rf transcripts uploads thistle.db",
-
"test": "bun test",
-
"test:integration": "bun test src/index.test.ts",
-
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
-
},
-
"devDependencies": {
-
"@biomejs/biome": "^2.3.2",
-
"@simplewebauthn/types": "^12.0.0",
-
"@types/bun": "latest"
-
},
-
"peerDependencies": {
-
"typescript": "^5"
-
},
-
"dependencies": {
-
"@polar-sh/sdk": "^0.41.5",
-
"@simplewebauthn/browser": "^13.2.2",
-
"@simplewebauthn/server": "^13.2.2",
-
"eventsource-client": "^1.2.0",
-
"lit": "^3.3.1",
-
"nanoid": "^5.1.6",
-
"ua-parser-js": "^2.0.6"
-
}
+
"name": "thistle",
+
"module": "src/index.ts",
+
"type": "module",
+
"private": true,
+
"scripts": {
+
"dev": "bun run src/index.ts --hot",
+
"clean": "rm -rf transcripts uploads thistle.db",
+
"test": "bun test",
+
"test:integration": "bun test src/index.test.ts",
+
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
+
},
+
"devDependencies": {
+
"@biomejs/biome": "^2.3.2",
+
"@simplewebauthn/types": "^12.0.0",
+
"@types/bun": "latest"
+
},
+
"peerDependencies": {
+
"typescript": "^5"
+
},
+
"dependencies": {
+
"@polar-sh/sdk": "^0.41.5",
+
"@simplewebauthn/browser": "^13.2.2",
+
"@simplewebauthn/server": "^13.2.2",
+
"eventsource-client": "^1.2.0",
+
"lit": "^3.3.1",
+
"nanoid": "^5.1.6",
+
"ua-parser-js": "^2.0.6"
+
}
}
+19 -1
public/favicon/site.webmanifest
···
-
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+
{
+
"name": "",
+
"short_name": "",
+
"icons": [
+
{
+
"src": "/android-chrome-192x192.png",
+
"sizes": "192x192",
+
"type": "image/png"
+
},
+
{
+
"src": "/android-chrome-512x512.png",
+
"sizes": "512x512",
+
"type": "image/png"
+
}
+
],
+
"theme_color": "#ffffff",
+
"background_color": "#ffffff",
+
"display": "standalone"
+
}
+3 -1
scripts/clear-rate-limits.ts
···
if (deletedCount === 0) {
console.log("ℹ️ No rate limit attempts to clear");
} else {
-
console.log(`✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? '' : 's'}`);
+
console.log(
+
`✅ Successfully cleared ${deletedCount} rate limit attempt${deletedCount === 1 ? "" : "s"}`,
+
);
}
+1 -1
scripts/send-test-emails.ts
···
import { sendEmail } from "../src/lib/email";
import {
-
verifyEmailTemplate,
passwordResetTemplate,
transcriptionCompleteTemplate,
+
verifyEmailTemplate,
} from "../src/lib/email-templates";
const targetEmail = process.argv[2];
+5 -5
src/components/admin-classes.ts
···
override async connectedCallback() {
super.connectedCallback();
-
+
// Check for subtab query parameter
const params = new URLSearchParams(window.location.search);
const subtab = params.get("subtab");
···
// Set default subtab in URL if on classes tab
this.setActiveTab(this.activeTab);
}
-
+
await this.loadData();
}
···
private async handleToggleArchive(classId: string) {
try {
// Find the class to toggle its archived state
-
const classToToggle = this.classes.find(c => c.id === classId);
+
const classToToggle = this.classes.find((c) => c.id === classId);
if (!classToToggle) return;
const response = await fetch(`/api/classes/${classId}/archive`, {
···
}
// Update local state instead of reloading
-
this.classes = this.classes.map(c =>
-
c.id === classId ? { ...c, archived: !c.archived } : c
+
this.classes = this.classes.map((c) =>
+
c.id === classId ? { ...c, archived: !c.archived } : c,
);
} catch {
this.error = "Failed to update class. Please try again.";
+19 -10
src/components/admin-pending-recordings.ts
···
this.isLoading = true;
this.error = null;
-
try {
-
// Get all classes with their transcriptions
-
const response = await fetch("/api/classes");
-
if (!response.ok) {
-
const data = await response.json();
-
throw new Error(data.error || "Failed to load classes");
-
}
+
try {
+
// Get all classes with their transcriptions
+
const response = await fetch("/api/classes");
+
if (!response.ok) {
+
const data = await response.json();
+
throw new Error(data.error || "Failed to load classes");
+
}
const data = await response.json();
const classesGrouped = data.classes || {};
···
this.recordings = pendingRecordings;
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to load pending recordings. Please try again.";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to load pending recordings. Please try again.";
} finally {
this.isLoading = false;
}
···
// Reload recordings
await this.loadRecordings();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to approve recording. Please try again.";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to approve recording. Please try again.";
}
}
···
// Reload recordings
await this.loadRecordings();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to delete recording. Please try again.";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to delete recording. Please try again.";
}
}
+8 -2
src/components/admin-transcriptions.ts
···
this.transcriptions = await response.json();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to load transcriptions. Please try again.";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to load transcriptions. Please try again.";
} finally {
this.isLoading = false;
}
···
await this.loadTranscriptions();
this.dispatchEvent(new CustomEvent("transcription-deleted"));
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to delete transcription. Please try again.";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to delete transcription. Please try again.";
}
}
+64 -30
src/components/admin-users.ts
···
this.users = await response.json();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to load users. Please try again.";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to load users. Please try again.";
} finally {
this.isLoading = false;
}
···
await this.loadUsers();
}
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to update user role";
+
this.error =
+
err instanceof Error ? err.message : "Failed to update user role";
select.value = oldRole;
}
}
···
}
// Remove user from local array instead of reloading
-
this.users = this.users.filter(u => u.id !== userId);
+
this.users = this.users.filter((u) => u.id !== userId);
this.dispatchEvent(new CustomEvent("user-deleted"));
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to delete user. Please try again.";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to delete user. Please try again.";
}
}
-
private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) {
+
private handleRevokeClick(
+
userId: number,
+
email: string,
+
subscriptionId: string,
+
event: Event,
+
) {
event.stopPropagation();
// If this is a different item or timeout expired, reset
···
this.deleteState = null;
}, 1000);
-
this.deleteState = { id: userId, type: "revoke", clicks: newClicks, timeout };
+
this.deleteState = {
+
id: userId,
+
type: "revoke",
+
clicks: newClicks,
+
timeout,
+
};
}
-
private async performRevokeSubscription(userId: number, _email: string, subscriptionId: string) {
+
private async performRevokeSubscription(
+
userId: number,
+
_email: string,
+
subscriptionId: string,
+
) {
this.revokingSubscriptions.add(userId);
this.requestUpdate();
this.error = null;
···
await this.loadUsers();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to revoke subscription";
+
this.error =
+
err instanceof Error ? err.message : "Failed to revoke subscription";
this.revokingSubscriptions.delete(userId);
}
}
···
if (userId === 0) {
return;
}
-
+
// Don't open modal if clicking on delete button, revoke button, sync button, or role select
if (
(event.target as HTMLElement).closest(".delete-btn") ||
···
private get filteredUsers() {
const query = this.searchQuery.toLowerCase();
-
+
// Filter users based on search query
let filtered = this.users.filter(
(u) =>
u.email.toLowerCase().includes(query) ||
u.name?.toLowerCase().includes(query),
);
-
+
// Hide ghost user unless specifically searched for
-
if (!query.includes("deleted") && !query.includes("ghost") && !query.includes("system")) {
-
filtered = filtered.filter(u => u.id !== 0);
+
if (
+
!query.includes("deleted") &&
+
!query.includes("ghost") &&
+
!query.includes("system")
+
) {
+
filtered = filtered.filter((u) => u.id !== 0);
}
-
+
return filtered;
}
···
<div class="users-grid">
${filtered.map(
(u) => html`
-
<div class="user-card ${u.id === 0 ? 'system' : ''}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>
+
<div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>
<div class="card-header">
<div class="user-info">
<img
···
<div class="user-email">${u.email}</div>
</div>
</div>
-
${u.id === 0
-
? html`<span class="system-badge">System</span>`
-
: u.role === "admin"
-
? html`<span class="admin-badge">Admin</span>`
-
: ""
-
}
+
${
+
u.id === 0
+
? html`<span class="system-badge">System</span>`
+
: u.role === "admin"
+
? html`<span class="admin-badge">Admin</span>`
+
: ""
+
}
</div>
<div class="meta-row">
···
<div class="meta-item">
<div class="meta-label">Subscription</div>
<div class="meta-value">
-
${u.subscription_status
-
? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
-
: html`<span class="subscription-badge none">None</span>`
-
}
+
${
+
u.subscription_status
+
? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`
+
: html`<span class="subscription-badge none">None</span>`
+
}
</div>
</div>
<div class="meta-item">
···
</div>
<div class="actions">
-
${u.id === 0
-
? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`
-
: html`
+
${
+
u.id === 0
+
? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`
+
: html`
<select
class="role-select"
.value=${u.role}
···
?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}
@click=${(e: Event) => {
if (u.subscription_id) {
-
this.handleRevokeClick(u.id, u.email, u.subscription_id, e);
+
this.handleRevokeClick(
+
u.id,
+
u.email,
+
u.subscription_id,
+
e,
+
);
}
}}
>
···
${this.getDeleteButtonText(u.id, "user")}
</button>
`
-
}
+
}
</div>
</div>
`,
+10 -10
src/components/auth.ts
···
}
const data = await response.json();
-
+
if (data.email_verification_required) {
this.needsEmailVerification = true;
this.password = "";
···
}
const data = await response.json();
-
+
if (data.email_verification_required) {
this.needsEmailVerification = true;
this.password = "";
···
private startResendTimer(sentAtTimestamp: number) {
// Use provided timestamp
this.codeSentAt = sentAtTimestamp;
-
+
// Clear existing interval if any
if (this.resendInterval !== null) {
clearInterval(this.resendInterval);
}
-
+
// Update timer based on elapsed time
const updateTimer = () => {
if (this.codeSentAt === null) return;
-
+
const now = Math.floor(Date.now() / 1000);
const elapsed = now - this.codeSentAt;
-
const remaining = Math.max(0, (5 * 60) - elapsed);
+
const remaining = Math.max(0, 5 * 60 - elapsed);
this.resendCodeTimer = remaining;
-
+
if (remaining <= 0) {
if (this.resendInterval !== null) {
clearInterval(this.resendInterval);
···
}
}
};
-
+
// Update immediately
updateTimer();
-
+
// Then update every second
this.resendInterval = window.setInterval(updateTimer, 1000);
}
···
private formatTimer(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
-
return `${mins}:${secs.toString().padStart(2, '0')}`;
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
override disconnectedCallback() {
+11 -7
src/components/class-view.ts
···
: ""
}
-
${!canAccessTranscriptions ? html`
+
${
+
!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`
+
`
+
: html`
<div class="search-upload">
<input
type="text"
···
<p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p>
</div>
`
-
: html`
+
: html`
${this.filteredTranscriptions.map(
-
(t) => html`
+
(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
+18 -7
src/components/reset-password-form.ts
···
override async updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
-
+
// When token property changes and we don't have email yet, load it
-
if (changedProperties.has('token') && this.token && !this.email && !this.isLoadingEmail) {
+
if (
+
changedProperties.has("token") &&
+
this.token &&
+
!this.email &&
+
!this.isLoadingEmail
+
) {
await this.loadEmail();
}
}
···
this.email = data.email;
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to verify reset token";
+
this.error =
+
err instanceof Error ? err.message : "Failed to verify reset token";
} finally {
this.isLoadingEmail = false;
}
···
<h1 class="reset-title">Reset Password</h1>
<form @submit=${this.handleSubmit}>
-
${this.error
-
? html`<div class="error-banner">${this.error}</div>`
-
: ""}
+
${
+
this.error
+
? html`<div class="error-banner">${this.error}</div>`
+
: ""
+
}
<div class="form-group">
<label for="password">New Password</label>
···
}
// Hash password client-side with user's email
-
const hashedPassword = await hashPasswordClient(this.password, this.email);
+
const hashedPassword = await hashPasswordClient(
+
this.password,
+
this.email,
+
);
const response = await fetch("/api/auth/reset-password", {
method: "POST",
+8 -3
src/components/transcription.ts
···
}
override render() {
-
const canUpload = this.serviceAvailable && (this.hasSubscription || this.isAdmin);
+
const canUpload =
+
this.serviceAvailable && (this.hasSubscription || this.isAdmin);
return html`
-
${!this.hasSubscription && !this.isAdmin ? 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}
+14 -6
src/components/user-modal.ts
···
e.preventDefault();
const form = e.target as HTMLFormElement;
const input = form.querySelector('input[type="email"]') as HTMLInputElement;
-
const checkbox = form.querySelector('input[type="checkbox"]') as HTMLInputElement;
+
const checkbox = form.querySelector(
+
'input[type="checkbox"]',
+
) as HTMLInputElement;
const email = input.value.trim();
const skipVerification = checkbox?.checked || false;
···
submitBtn.textContent = "Sending...";
try {
-
const res = await fetch(`/api/admin/users/${this.userId}/password-reset`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
+
const res = await fetch(
+
`/api/admin/users/${this.userId}/password-reset`,
+
{
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
},
+
);
if (!res.ok) {
const data = await res.json();
···
"Password reset email sent successfully. The user will receive a link to set a new password.",
);
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to send password reset email";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to send password reset email";
} finally {
submitBtn.disabled = false;
submitBtn.textContent = "Send Reset Email";
+106 -44
src/components/user-settings.ts
···
canceled_at: number | null;
}
-
type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger";
+
type SettingsPage =
+
| "account"
+
| "sessions"
+
| "passkeys"
+
| "billing"
+
| "notifications"
+
| "danger";
@customElement("user-settings")
export class UserSettings extends LitElement {
···
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();
···
}
private isValidTab(tab: string): boolean {
-
return ["account", "sessions", "passkeys", "billing", "notifications", "danger"].includes(tab);
+
return [
+
"account",
+
"sessions",
+
"passkeys",
+
"billing",
+
"notifications",
+
"danger",
+
].includes(tab);
}
private setTab(tab: SettingsPage) {
···
// Reload passkeys
await this.loadPasskeys();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to delete passkey";
+
this.error =
+
err instanceof Error ? err.message : "Failed to delete passkey";
}
}
···
this.error = "";
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
-
+
if (!response.ok) {
const data = await response.json();
this.error = data.error || "Failed to logout";
return;
}
-
+
window.location.href = "/";
} catch (err) {
this.error = err instanceof Error ? err.message : "Failed to logout";
···
this.deletingAccount = true;
this.error = "";
document.body.style.cursor = "wait";
-
+
try {
const response = await fetch("/api/user", {
method: "DELETE",
···
return html`
<div class="content-inner">
-
${this.error ? html`
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Profile Information</h2>
···
? html`
<div class="success-message" style="margin-bottom: 1rem;">
${this.emailChangeMessage}
-
${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ''}
+
${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ""}
</div>
<div class="field-row">
<div class="field-value">${this.user.email}</div>
</div>
: this.editingEmail
-
? html`
+
? html`
<div style="display: flex; gap: 0.5rem; align-items: center;">
<input
type="email"
···
@click=${this.handleUpdateEmail}
?disabled=${this.updatingEmail}
-
${this.updatingEmail ? html`<span class="spinner"></span>` : 'Save'}
+
${this.updatingEmail ? html`<span class="spinner"></span>` : "Save"}
</button>
<button
class="btn btn-neutral btn-small"
···
</button>
</div>
-
: html`
+
: html`
<div class="field-row">
<div class="field-value">${this.user.email}</div>
<button
···
renderSessionsPage() {
return html`
<div class="content-inner">
-
${this.error ? html`
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Active Sessions</h2>
${
···
`;
-
const hasActiveSubscription = this.subscription && (
-
this.subscription.status === "active" ||
-
this.subscription.status === "trialing"
-
);
+
const hasActiveSubscription =
+
this.subscription &&
+
(this.subscription.status === "active" ||
+
this.subscription.status === "trialing");
if (this.subscription && !hasActiveSubscription) {
// Has a subscription but it's not active (canceled, expired, etc.)
-
const statusColor =
-
this.subscription.status === "canceled" ? "var(--accent)" :
-
"var(--secondary)";
+
const statusColor =
+
this.subscription.status === "canceled"
+
? "var(--accent)"
+
: "var(--secondary)";
return html`
<div class="content-inner">
-
${this.error ? html`
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Subscription</h2>
···
</div>
</div>
-
${this.subscription.canceled_at ? html`
+
${
+
this.subscription.canceled_at
+
? html`
<div class="field-group">
<label class="field-label">Canceled At</label>
<div class="field-value" style="color: var(--accent);">
${this.formatDate(this.subscription.canceled_at)}
</div>
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="field-group" style="margin-top: 2rem;">
<button
···
if (hasActiveSubscription) {
return html`
<div class="content-inner">
-
${this.error ? html`
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Subscription</h2>
···
">
${this.subscription.status}
</span>
-
${this.subscription.cancel_at_period_end ? html`
+
${
+
this.subscription.cancel_at_period_end
+
? html`
<span style="color: var(--accent); font-size: 0.875rem;">
(Cancels at end of period)
</span>
-
` : ""}
+
`
+
: ""
+
}
</div>
</div>
-
${this.subscription.current_period_start && this.subscription.current_period_end ? html`
+
${
+
this.subscription.current_period_start &&
+
this.subscription.current_period_end
+
? html`
<div class="field-group">
<label class="field-label">Current Period</label>
<div class="field-value">
···
${this.formatDate(this.subscription.current_period_end)}
</div>
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="field-group" style="margin-top: 2rem;">
<button
···
return html`
<div class="content-inner">
-
${this.error ? html`
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Billing & Subscription</h2>
<p class="field-description" style="margin-bottom: 1.5rem;">
···
renderDangerPage() {
return html`
<div class="content-inner">
-
${this.error ? html`
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="section danger-section">
<h2 class="section-title">Delete Account</h2>
<p class="danger-text">
···
renderNotificationsPage() {
return html`
<div class="content-inner">
-
${this.error ? html`
+
${
+
this.error
+
? html`
<div class="error-banner">
${this.error}
</div>
-
` : ""}
+
`
+
: ""
+
}
<div class="section">
<h2 class="section-title">Email Notifications</h2>
<p style="color: var(--text); margin-bottom: 1rem;">
···
const target = e.target as HTMLInputElement;
this.emailNotificationsEnabled = target.checked;
this.error = "";
-
+
try {
const response = await fetch("/api/user/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
-
email_notifications_enabled: this.emailNotificationsEnabled,
+
email_notifications_enabled:
+
this.emailNotificationsEnabled,
}),
});
-
+
if (!response.ok) {
const data = await response.json();
-
throw new Error(data.error || "Failed to update notification settings");
+
throw new Error(
+
data.error || "Failed to update notification settings",
+
);
} catch (err) {
// Revert on error
this.emailNotificationsEnabled = !target.checked;
target.checked = !target.checked;
-
this.error = err instanceof Error ? err.message : "Failed to update notification settings";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to update notification settings";
}}
/>
+153 -80
src/index.ts
···
import {
authenticateUser,
cleanupExpiredSessions,
+
consumeEmailChangeToken,
+
consumePasswordResetToken,
+
createEmailChangeToken,
+
createEmailVerificationToken,
+
createPasswordResetToken,
createSession,
createUser,
deleteAllUserSessions,
···
getUserByEmail,
getUserBySession,
getUserSessionsForUser,
+
getVerificationCodeSentAt,
+
isEmailVerified,
type UserRole,
updateUserAvatar,
updateUserEmail,
···
updateUserName,
updateUserPassword,
updateUserRole,
-
createEmailVerificationToken,
-
verifyEmailToken,
+
verifyEmailChangeToken,
verifyEmailCode,
-
isEmailVerified,
-
getVerificationCodeSentAt,
-
createPasswordResetToken,
+
verifyEmailToken,
verifyPasswordResetToken,
-
consumePasswordResetToken,
-
createEmailChangeToken,
-
verifyEmailChangeToken,
-
consumeEmailChangeToken,
} from "./lib/auth";
import {
addToWaitlist,
···
toggleClassArchive,
updateMeetingTime,
} from "./lib/classes";
+
import { sendEmail } from "./lib/email";
+
import {
+
emailChangeTemplate,
+
passwordResetTemplate,
+
verifyEmailTemplate,
+
} from "./lib/email-templates";
import { AuthErrors, handleError, ValidationErrors } from "./lib/errors";
import {
hasActiveSubscription,
···
verifyAndAuthenticatePasskey,
verifyAndCreatePasskey,
} from "./lib/passkey";
-
import { enforceRateLimit, clearRateLimit } from "./lib/rate-limit";
+
import { clearRateLimit, enforceRateLimit } from "./lib/rate-limit";
import { getTranscriptVTT } from "./lib/transcript-storage";
import {
MAX_FILE_SIZE,
···
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
-
import { sendEmail } from "./lib/email";
-
import {
-
verifyEmailTemplate,
-
passwordResetTemplate,
-
emailChangeTemplate,
-
} from "./lib/email-templates";
import adminHTML from "./pages/admin.html";
import checkoutHTML from "./pages/checkout.html";
import classHTML from "./pages/class.html";
···
customerId: customer.id,
});
-
if (!subscriptions.result.items || subscriptions.result.items.length === 0) {
+
if (
+
!subscriptions.result.items ||
+
subscriptions.result.items.length === 0
+
) {
console.log(`[Sync] No subscriptions found for customer ${customer.id}`);
return;
}
// Filter to only active/trialing/past_due subscriptions (not canceled/expired)
const currentSubscriptions = subscriptions.result.items.filter(
-
(sub) => sub.status === 'active' || sub.status === 'trialing' || sub.status === 'past_due'
+
(sub) =>
+
sub.status === "active" ||
+
sub.status === "trialing" ||
+
sub.status === "past_due",
);
if (currentSubscriptions.length === 0) {
-
console.log(`[Sync] No current subscriptions found for customer ${customer.id}`);
+
console.log(
+
`[Sync] No current subscriptions found for customer ${customer.id}`,
+
);
return;
}
···
// Don't throw - registration should succeed even if sync fails
}
}
-
// Sync with Whisper DB on startup
try {
···
);
}
const user = await createUser(email, password, name);
-
+
// Send verification email - MUST succeed for registration to complete
const { code, token, sentAt } = createEmailVerificationToken(user.id);
-
+
try {
await sendEmail({
to: user.email,
···
} catch (err) {
console.error("[Email] Failed to send verification email:", err);
// Rollback user creation - direct DB delete since user was just created
-
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [user.id]);
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [
+
user.id,
+
]);
db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]);
db.run("DELETE FROM users WHERE id = ?", [user.id]);
return Response.json(
-
{ error: "Failed to send verification email. Please try again later." },
+
{
+
error:
+
"Failed to send verification email. Please try again later.",
+
},
{ status: 500 },
);
}
-
+
// Attempt to sync existing Polar subscriptions (after email succeeds)
syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => {
// Silent fail - don't block registration
});
-
+
// Clear rate limits on successful registration
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
clearRateLimit("register", email, ipAddress);
-
+
// Return success but indicate email verification is needed
// Don't create session yet - they need to verify first
return Response.json(
-
{
+
{
user: { id: user.id, email: user.email },
email_verification_required: true,
verification_code_sent_at: sentAt,
···
{ status: 401 },
);
}
-
+
// Clear rate limits on successful authentication
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
clearRateLimit("login", email, ipAddress);
-
+
// Check if email is verified
if (!isEmailVerified(user.id)) {
let codeSentAt = getVerificationCodeSentAt(user.id);
-
+
// If no verification code exists, auto-send one
if (!codeSentAt) {
-
const { code, token, sentAt } = createEmailVerificationToken(user.id);
+
const { code, token, sentAt } = createEmailVerificationToken(
+
user.id,
+
);
codeSentAt = sentAt;
-
+
try {
await sendEmail({
to: user.email,
···
}),
});
} catch (err) {
-
console.error("[Email] Failed to send verification email on login:", err);
+
console.error(
+
"[Email] Failed to send verification email on login:",
+
err,
+
);
// Don't fail login - just return null timestamp so client can try resend
codeSentAt = null;
}
}
-
+
return Response.json(
-
{
+
{
user: { id: user.id, email: user.email },
email_verification_required: true,
verification_code_sent_at: codeSentAt,
···
{ status: 200 },
);
}
-
+
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
···
return new Response(null, {
status: 302,
headers: {
-
"Location": "/classes",
+
Location: "/classes",
"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
},
});
···
// Get user by email
const user = getUserByEmail(email);
if (!user) {
-
return Response.json(
-
{ error: "User not found" },
-
{ status: 404 },
-
);
+
return Response.json({ error: "User not found" }, { status: 404 });
}
// Check if already verified
···
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
-
{
+
{
message: "Email verified successfully",
email_verified: true,
user: { id: user.id, email: user.email },
···
POST: async (req) => {
try {
const user = requireAuth(req);
-
+
// Rate limiting
const rateLimitError = enforceRateLimit(req, "resend-verification", {
account: { max: 3, windowSeconds: 60 * 60, email: user.email },
···
}
// Rate limiting by email
-
const rateLimitError = enforceRateLimit(req, "resend-verification-code", {
-
account: { max: 3, windowSeconds: 5 * 60, email },
-
});
+
const rateLimitError = enforceRateLimit(
+
req,
+
"resend-verification-code",
+
{
+
account: { max: 3, windowSeconds: 5 * 60, email },
+
},
+
);
if (rateLimitError) return rateLimitError;
// Get user by email
const user = getUserByEmail(email);
if (!user) {
// Don't reveal if user exists
-
return Response.json({ message: "If an account exists with that email, a verification code has been sent" });
+
return Response.json({
+
message:
+
"If an account exists with that email, a verification code has been sent",
+
});
}
// Check if already verified
···
}),
});
-
return Response.json({
+
return Response.json({
message: "Verification code sent",
verification_code_sent_at: sentAt,
});
···
const token = url.searchParams.get("token");
if (!token) {
-
return Response.json(
-
{ error: "Token required" },
-
{ status: 400 },
-
);
+
return Response.json({ error: "Token required" }, { status: 400 });
}
const userId = verifyPasswordResetToken(token);
···
// Get user's email for client-side password hashing
const user = db
-
.query<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?")
+
.query<{ email: string }, [number]>(
+
"SELECT email FROM users WHERE id = ?",
+
)
.get(userId);
if (!user) {
···
}),
});
-
return Response.json({
+
return Response.json({
success: true,
message: `Verification email sent to ${user.email}`,
-
pendingEmail: email
+
pendingEmail: email,
});
} catch (error) {
-
console.error("[Email] Failed to send email change verification:", error);
+
console.error(
+
"[Email] Failed to send email change verification:",
+
error,
+
);
return Response.json(
{ error: "Failed to send verification email" },
{ status: 500 },
···
const token = url.searchParams.get("token");
if (!token) {
-
return Response.redirect("/settings?tab=account&error=invalid-token", 302);
+
return Response.redirect(
+
"/settings?tab=account&error=invalid-token",
+
302,
+
);
const result = verifyEmailChangeToken(token);
if (!result) {
-
return Response.redirect("/settings?tab=account&error=expired-token", 302);
+
return Response.redirect(
+
"/settings?tab=account&error=expired-token",
+
302,
+
);
// Update the user's email
···
consumeEmailChangeToken(token);
// Redirect to settings with success message
-
return Response.redirect("/settings?tab=account&success=email-changed", 302);
+
return Response.redirect(
+
"/settings?tab=account&success=email-changed",
+
302,
+
);
} catch (error) {
console.error("[Email] Email change verification error:", error);
-
return Response.redirect("/settings?tab=account&error=verification-failed", 302);
+
return Response.redirect(
+
"/settings?tab=account&error=verification-failed",
+
302,
+
);
},
},
···
const body = await req.json();
const { email_notifications_enabled } = body;
if (typeof email_notifications_enabled !== "boolean") {
-
return Response.json({ error: "email_notifications_enabled must be a boolean" }, { status: 400 });
+
return Response.json(
+
{ error: "email_notifications_enabled must be a boolean" },
+
{ status: 400 },
+
);
try {
-
db.run("UPDATE users SET email_notifications_enabled = ? WHERE id = ?", [email_notifications_enabled ? 1 : 0, user.id]);
+
db.run(
+
"UPDATE users SET email_notifications_enabled = ? WHERE id = ?",
+
[email_notifications_enabled ? 1 : 0, user.id],
+
);
return Response.json({ success: true });
} catch {
return Response.json(
···
const transcriptionId = req.params.id;
// Verify ownership
const transcription = db
-
.query<{ id: string; user_id: number; class_id: string | null; status: string }, [string]>(
+
.query<
+
{
+
id: string;
+
user_id: number;
+
class_id: string | null;
+
status: string;
+
},
+
[string]
+
>(
"SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?",
.get(transcriptionId);
-
+
if (!transcription) {
return Response.json(
{ error: "Transcription not found" },
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
+
isClassMember = isUserEnrolledInClass(
+
user.id,
+
transcription.class_id,
+
);
// Allow access if: owner, admin, or enrolled in the class
···
// Require subscription only if accessing own transcription (not class)
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
+
if (
+
isOwner &&
+
!transcription.class_id &&
+
!isAdmin &&
+
!hasActiveSubscription(user.id)
+
) {
throw AuthErrors.subscriptionRequired();
// Event-driven SSE stream with reconnection support
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
+
isClassMember = isUserEnrolledInClass(
+
user.id,
+
transcription.class_id,
+
);
// Allow access if: owner, admin, or enrolled in the class
···
// Require subscription only if accessing own transcription (not class)
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
+
if (
+
isOwner &&
+
!transcription.class_id &&
+
!isAdmin &&
+
!hasActiveSubscription(user.id)
+
) {
throw AuthErrors.subscriptionRequired();
···
// If transcription belongs to a class, check enrollment
if (transcription.class_id) {
-
isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
+
isClassMember = isUserEnrolledInClass(
+
user.id,
+
transcription.class_id,
+
);
// Allow access if: owner, admin, or enrolled in the class
···
// Require subscription only if accessing own transcription (not class)
-
if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
+
if (
+
isOwner &&
+
!transcription.class_id &&
+
!isAdmin &&
+
!hasActiveSubscription(user.id)
+
) {
throw AuthErrors.subscriptionRequired();
···
.get(userId);
if (!user) {
-
return Response.json(
-
{ error: "User not found" },
-
{ status: 404 },
-
);
+
return Response.json({ error: "User not found" }, { status: 404 });
try {
···
}),
});
-
return Response.json({
+
return Response.json({
success: true,
-
message: "Password reset email sent"
+
message: "Password reset email sent",
});
} catch (error) {
console.error("[Admin] Password reset error:", error);
···
const body = await req.json();
-
const { email, skipVerification } = body as { email: string; skipVerification?: boolean };
+
const { email, skipVerification } = body as {
+
email: string;
+
skipVerification?: boolean;
+
};
if (!email || !email.includes("@")) {
return Response.json(
···
if (skipVerification) {
// Admin override: change email immediately without verification
updateUserEmailAddress(userId, email);
-
return Response.json({
+
return Response.json({
success: true,
-
message: "Email updated immediately (verification skipped)"
+
message: "Email updated immediately (verification skipped)",
});
+26 -24
src/lib/auth.ts
···
// Get user's subscription if they have one
const subscription = db
-
.query<{ id: string; status: string; cancel_at_period_end: number }, [number]>(
+
.query<
+
{ id: string; status: string; cancel_at_period_end: number },
+
[number]
+
>(
"SELECT id, status, cancel_at_period_end FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
)
.get(userId);
// Cancel subscription if it exists and is not already canceled or scheduled to cancel
if (
-
subscription &&
-
subscription.status !== 'canceled' &&
-
subscription.status !== 'expired' &&
+
subscription &&
+
subscription.status !== "canceled" &&
+
subscription.status !== "expired" &&
!subscription.cancel_at_period_end
) {
try {
···
"UPDATE transcriptions SET user_id = 0 WHERE user_id = ? AND class_id IS NOT NULL",
[userId],
);
-
db.run(
-
"DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL",
-
[userId],
-
);
+
db.run("DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL", [
+
userId,
+
]);
// Delete user (CASCADE will handle sessions, passkeys, subscriptions, class_members)
db.run("DELETE FROM users WHERE id = ?", [userId]);
···
* Email verification functions
*/
-
export function createEmailVerificationToken(userId: number): { code: string; token: string; sentAt: number } {
+
export function createEmailVerificationToken(userId: number): {
+
code: string;
+
token: string;
+
sentAt: number;
+
} {
// Generate a 6-digit code for user to enter
const code = Math.floor(100000 + Math.random() * 900000).toString();
const id = crypto.randomUUID();
···
"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
[id, userId, code, expiresAt],
);
-
+
// Store the URL token as a separate entry
db.run(
"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
···
const now = Math.floor(Date.now() / 1000);
const result = db
-
.query<
-
{ user_id: number; email: string },
-
[string, number]
-
>(
+
.query<{ user_id: number; email: string }, [string, number]>(
`SELECT evt.user_id, u.email
FROM email_verification_tokens evt
JOIN users u ON evt.user_id = u.id
···
return { userId: result.user_id, email: result.email };
}
-
export function verifyEmailCode(
-
userId: number,
-
code: string,
-
): boolean {
+
export function verifyEmailCode(userId: number, code: string): boolean {
const now = Math.floor(Date.now() / 1000);
const result = db
-
.query<
-
{ user_id: number },
-
[number, string, number]
-
>(
+
.query<{ user_id: number }, [number, string, number]>(
`SELECT user_id
FROM email_verification_tokens
WHERE user_id = ? AND token = ? AND expires_at > ?`,
···
* Email change functions
*/
-
export function createEmailChangeToken(userId: number, newEmail: string): string {
+
export function createEmailChangeToken(
+
userId: number,
+
newEmail: string,
+
): string {
const token = crypto.randomUUID();
const id = crypto.randomUUID();
const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours
···
return token;
}
-
export function verifyEmailChangeToken(token: string): { userId: number; newEmail: string } | null {
+
export function verifyEmailChangeToken(
+
token: string,
+
): { userId: number; newEmail: string } | null {
const now = Math.floor(Date.now() / 1000);
const result = db
-1
src/lib/client-auth.ts
···
const hashArray = Array.from(hashBuffer);
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
-
+1 -2
src/lib/crypto-fallback.ts
···
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
const ch = (x: number, y: number, z: number) => (x & y) ^ (~x & z);
-
const maj = (x: number, y: number, z: number) =>
-
(x & y) ^ (x & z) ^ (y & z);
+
const maj = (x: number, y: number, z: number) => (x & y) ^ (x & z) ^ (y & z);
const s0 = (x: number) => rotr(x, 2) ^ rotr(x, 13) ^ rotr(x, 22);
const s1 = (x: number) => rotr(x, 6) ^ rotr(x, 11) ^ rotr(x, 25);
const g0 = (x: number) => rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
+31 -11
src/lib/email-change.test.ts
···
-
import { test, expect } from "bun:test";
+
import { expect, test } from "bun:test";
import db from "../db/schema";
import {
+
consumeEmailChangeToken,
+
createEmailChangeToken,
createUser,
-
createEmailChangeToken,
+
getUserByEmail,
+
updateUserEmail,
verifyEmailChangeToken,
-
consumeEmailChangeToken,
-
updateUserEmail,
-
getUserByEmail,
} from "./auth";
test("email change token lifecycle", async () => {
// Create a test user with unique email
const timestamp = Date.now();
-
const user = await createUser(`test-email-change-${timestamp}@example.com`, "password123", "Test User");
+
const user = await createUser(
+
`test-email-change-${timestamp}@example.com`,
+
"password123",
+
"Test User",
+
);
// Create an email change token
const newEmail = `new-email-${timestamp}@example.com`;
···
expect(result?.newEmail).toBe(newEmail);
// Update the email
-
updateUserEmail(result!.userId, result!.newEmail);
+
if (result) {
+
updateUserEmail(result.userId, result.newEmail);
+
}
// Consume the token
consumeEmailChangeToken(token);
···
test("email change token expires", async () => {
// Create a test user with unique email
const timestamp = Date.now();
-
const user = await createUser(`test-expire-${timestamp}@example.com`, "password123", "Test User");
+
const user = await createUser(
+
`test-expire-${timestamp}@example.com`,
+
"password123",
+
"Test User",
+
);
// Create an email change token
const newEmail = `new-expire-${timestamp}@example.com`;
···
test("only one email change token per user", async () => {
// Create a test user with unique email
const timestamp = Date.now();
-
const user = await createUser(`test-single-token-${timestamp}@example.com`, "password123", "Test User");
+
const user = await createUser(
+
`test-single-token-${timestamp}@example.com`,
+
"password123",
+
"Test User",
+
);
// Create first token
-
const token1 = createEmailChangeToken(user.id, `email1-${timestamp}@example.com`);
+
const token1 = createEmailChangeToken(
+
user.id,
+
`email1-${timestamp}@example.com`,
+
);
// Create second token (should delete first)
-
const token2 = createEmailChangeToken(user.id, `email2-${timestamp}@example.com`);
+
const token2 = createEmailChangeToken(
+
user.id,
+
`email2-${timestamp}@example.com`,
+
);
// First token should be invalid
const result1 = verifyEmailChangeToken(token1);
+6 -3
src/lib/email-templates.ts
···
<p>Your transcription is ready!</p>
<div class="info-box">
-
${options.className ? `
+
${
+
options.className
+
? `
<p class="info-box-label">Class</p>
<p class="info-box-value">${options.className}</p>
<hr class="info-box-divider">
-
` : ''}
+
`
+
: ""
+
}
<p class="info-box-label">File</p>
<p class="info-box-value">${options.originalFilename}</p>
</div>
···
</html>
`.trim();
}
-
+10 -12
src/lib/email-verification.test.ts
···
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import db from "../db/schema";
import {
-
createUser,
+
consumePasswordResetToken,
createEmailVerificationToken,
-
verifyEmailToken,
+
createPasswordResetToken,
+
createUser,
isEmailVerified,
-
createPasswordResetToken,
+
verifyEmailToken,
verifyPasswordResetToken,
-
consumePasswordResetToken,
} from "./auth";
describe("Email Verification", () => {
···
afterEach(() => {
// Cleanup
db.run("DELETE FROM users WHERE email = ?", [testEmail]);
-
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [
-
userId,
-
]);
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]);
});
test("creates verification token", () => {
···
const token = createPasswordResetToken(userId);
// Manually expire the token
-
db.run(
-
"UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?",
-
[Math.floor(Date.now() / 1000) - 100, token],
-
);
+
db.run("UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?", [
+
Math.floor(Date.now() / 1000) - 100,
+
token,
+
]);
const verifiedUserId = verifyPasswordResetToken(token);
expect(verifiedUserId).toBeNull();
+12 -10
src/lib/rate-limit.ts
···
return null; // Allowed
}
-
export function clearRateLimit(endpoint: string, email?: string, ipAddress?: string): void {
+
export function clearRateLimit(
+
endpoint: string,
+
email?: string,
+
ipAddress?: string,
+
): void {
// Clear account-based rate limits
if (email) {
-
db.run(
-
"DELETE FROM rate_limit_attempts WHERE key = ?",
-
[`${endpoint}:account:${email.toLowerCase()}`]
-
);
+
db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [
+
`${endpoint}:account:${email.toLowerCase()}`,
+
]);
}
-
+
// Clear IP-based rate limits
if (ipAddress) {
-
db.run(
-
"DELETE FROM rate_limit_attempts WHERE key = ?",
-
[`${endpoint}:ip:${ipAddress}`]
-
);
+
db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [
+
`${endpoint}:ip:${ipAddress}`,
+
]);
}
}
+3 -6
src/lib/transcription.ts
···
private async deleteWhisperJob(jobId: string) {
try {
-
const response = await fetch(
-
`${this.serviceUrl}/transcribe/${jobId}`,
-
{
-
method: "DELETE",
-
},
-
);
+
const response = await fetch(`${this.serviceUrl}/transcribe/${jobId}`, {
+
method: "DELETE",
+
});
if (response.ok) {
console.log(`[Cleanup] Deleted job ${jobId} from Murmur`);
} else {