diff --git a/.gitignore b/.gitignore index b7391b5..e560895 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ perf-results-*.json # System Files .DS_Store Thumbs.db +# pygienium run-state and check artifacts +.pygienium/ diff --git a/bun.lockb b/bun.lockb index bef2dfe..1aafab5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index fa94c59..7e1a5ed 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@aws-sdk/s3-request-presigner": "^3.953.0", "@clerk/backend": "^3.12.0", "@libsql/client": "^0.15.15", - "@motionone/solid": "^10.16.4", "@sentry/solidstart": "^10.67.0", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.0", @@ -26,13 +25,11 @@ "@tailwindcss/vite": "^4.0.7", "@tiptap/core": "^3.14.0", "@tiptap/extension-code-block-lowlight": "^3.14.0", - "@tiptap/extension-color": "^3.14.0", "@tiptap/extension-details": "^3.14.0", "@tiptap/extension-details-content": "^2.26.2", "@tiptap/extension-details-summary": "^2.26.2", "@tiptap/extension-image": "^3.14.0", "@tiptap/extension-link": "^3.14.0", - "@tiptap/extension-list-item": "^3.14.0", "@tiptap/extension-subscript": "^3.14.0", "@tiptap/extension-superscript": "^3.14.0", "@tiptap/extension-table": "^3.14.0", @@ -42,13 +39,11 @@ "@tiptap/extension-task-item": "^3.14.0", "@tiptap/extension-task-list": "^3.14.0", "@tiptap/extension-text-align": "^3.14.0", - "@tiptap/extension-text-style": "^3.14.0", "@tiptap/pm": "^3.14.0", "@tiptap/starter-kit": "^3.14.0", "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", "@tursodatabase/api": "^1.9.2", - "@typeschema/valibot": "^0.13.4", "bcrypt": "^6.0.0", "es-toolkit": "^1.43.0", "fast-diff": "^1.3.0", @@ -69,18 +64,11 @@ "node": "24.x" }, "devDependencies": { - "@playwright/test": "^1.57.0", "@sentry/vite-plugin": "^5.4.0", "@tailwindcss/typography": "^0.5.19", "@types/bcrypt": "^6.0.0", - "@types/fast-diff": "^1.2.2", - "chrome-launcher": "^1.2.1", - "lighthouse": "^13.0.1", "playwright": "^1.57.0", "prettier": "^3.7.4", - "prettier-plugin-tailwindcss": "^0.7.2", - "rollup-plugin-visualizer": "^6.0.5", - "trpc-panel": "^1.3.4", - "vite-bundle-visualizer": "^1.2.1" + "prettier-plugin-tailwindcss": "^0.7.2" } } diff --git a/scripts/perf-compare.ts b/scripts/perf-compare.ts index e81827c..8695e6c 100644 --- a/scripts/perf-compare.ts +++ b/scripts/perf-compare.ts @@ -96,7 +96,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) { "───────────────────────────────────────────────────────────────────\n" ); - // Compare each page for (const baseResult of baseline.results) { const optResult = optimized.results.find((r) => r.page === baseResult.page); if (!optResult) continue; @@ -107,7 +106,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) { console.log(`\n📄 ${baseResult.page}`); console.log("─".repeat(70)); - // Core Web Vitals console.log("\n Core Web Vitals:"); const fcpDiff = opt.fcp - base.fcp; @@ -121,7 +119,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) { ` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})` ); - // Loading Metrics console.log("\n Loading Metrics:"); const ttfbDiff = opt.ttfb - base.ttfb; @@ -148,7 +145,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) { ` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}` ); - // Resource Loading console.log("\n Resources:"); const reqDiff = opt.totalRequests - base.totalRequests; @@ -185,7 +181,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) { ); } - // Overall Summary console.log( "\n\n═══════════════════════════════════════════════════════════════════" ); @@ -338,7 +333,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) { ); } - // Specific findings const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests); if (reqPercent < -5) { console.log( diff --git a/scripts/perf-test.ts b/scripts/perf-test.ts index 32ddade..5a0c9d7 100644 --- a/scripts/perf-test.ts +++ b/scripts/perf-test.ts @@ -61,7 +61,6 @@ const BASE_URL = process.env.TEST_URL || "http://localhost:3000"; const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10); const WARMUP_RUNS = 1; -// Pages to test const TEST_PAGES: PageTestConfig[] = [ { name: "Home", path: "/" }, { name: "Blog Index", path: "/blog" }, @@ -78,7 +77,6 @@ const TEST_PAGES: PageTestConfig[] = [ { name: "404", path: "/404" } ]; -// Add additional blog post path if provided if (process.env.TEST_BLOG_POST) { TEST_PAGES.push({ name: "Custom Blog Post", @@ -102,7 +100,6 @@ async function setupPerformanceObservers(page: Page) { interactions: [] as number[] }; - // Observe LCP if ("PerformanceObserver" in window) { try { const lcpObserver = new PerformanceObserver((entryList) => { @@ -118,10 +115,8 @@ async function setupPerformanceObservers(page: Page) { buffered: true }); } catch (e) { - // LCP not supported } - // Observe CLS try { const clsObserver = new PerformanceObserver((entryList) => { for (const entry of entryList.getEntries()) { @@ -138,10 +133,8 @@ async function setupPerformanceObservers(page: Page) { }); clsObserver.observe({ type: "layout-shift", buffered: true }); } catch (e) { - // CLS not supported } - // Observe FID (first input) try { const fidObserver = new PerformanceObserver((entryList) => { const firstInput = entryList.getEntries()[0] as any; @@ -154,10 +147,8 @@ async function setupPerformanceObservers(page: Page) { }); fidObserver.observe({ type: "first-input", buffered: true }); } catch (e) { - // FID not supported } - // Observe long tasks try { const longTaskObserver = new PerformanceObserver((entryList) => { for (const entry of entryList.getEntries()) { @@ -166,10 +157,8 @@ async function setupPerformanceObservers(page: Page) { }); longTaskObserver.observe({ type: "longtask", buffered: true }); } catch (e) { - // Long tasks not supported } - // Observe INP (event timing for interactions) try { const inpObserver = new PerformanceObserver((entryList) => { for (const entry of entryList.getEntries()) { @@ -194,7 +183,6 @@ async function setupPerformanceObservers(page: Page) { }); inpObserver.observe({ type: "event", buffered: true }); } catch (e) { - // Event timing not supported } } }); @@ -203,18 +191,14 @@ async function setupPerformanceObservers(page: Page) { async function collectPerformanceMetrics( page: Page ): Promise { - // Wait for page to be loaded await page.waitForLoadState("load"); - // Wait a bit longer for LCP to settle (it can change as content loads) await page.waitForTimeout(1000); - // Additional wait for any remaining network activity await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => { // Ignore timeout - networkidle may never happen for some pages }); - // Collect comprehensive performance metrics const metrics = await page.evaluate(() => { const perf = performance.getEntriesByType( "navigation" @@ -222,7 +206,6 @@ async function collectPerformanceMetrics( const paint = performance.getEntriesByType("paint"); const fcp = paint.find((entry) => entry.name === "first-contentful-paint"); - // Get metrics from our observers const observedMetrics = (window as any).__perfMetrics || { lcp: 0, cls: 0, @@ -232,7 +215,6 @@ async function collectPerformanceMetrics( interactions: [] }; - // Fallback to direct API if observers didn't capture anything let lcp = observedMetrics.lcp; let cls = observedMetrics.cls; let fid = observedMetrics.fid; @@ -258,7 +240,6 @@ async function collectPerformanceMetrics( .reduce((sum: number, entry: any) => sum + entry.value, 0); } - // Calculate INP from event timing entries if not already captured if (inp === 0) { const eventEntries = performance.getEntriesByType("event") as any[]; const interactionLatencies = eventEntries @@ -275,7 +256,6 @@ async function collectPerformanceMetrics( } } - // Get resource timing const resources = performance.getEntriesByType( "resource" ) as PerformanceResourceTiming[]; @@ -318,7 +298,6 @@ async function collectPerformanceMetrics( } }); - // Calculate long task duration let taskDuration = 0; if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) { taskDuration = observedMetrics.longTasks.reduce( @@ -327,7 +306,6 @@ async function collectPerformanceMetrics( ); } - // Get more granular performance entries let jsExecutionTime = 0; let layoutDuration = 0; let paintDuration = 0; @@ -339,7 +317,6 @@ async function collectPerformanceMetrics( } }); - // Check for script evaluation entries const entries = performance.getEntries(); entries.forEach((entry: any) => { if (entry.entryType === "measure") { @@ -399,7 +376,6 @@ async function testPagePerformance( ` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n` ); - // Warmup runs (not counted) for (let i = 0; i < WARMUP_RUNS; i++) { const context = await browser.newContext(); const page = await context.newPage(); @@ -410,20 +386,16 @@ async function testPagePerformance( console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`); } - // Measured runs for (let i = 0; i < RUNS_PER_PAGE; i++) { console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`); - // Create new context for each run to ensure clean state const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } }); const page = await context.newPage(); - // Setup performance observers before navigation await setupPerformanceObservers(page); - // Navigate and collect metrics await page.goto(url, { waitUntil: "load" }); const metrics = await collectPerformanceMetrics(page); @@ -436,7 +408,6 @@ async function testPagePerformance( ); } - // Calculate statistics const average = calculateAverage(runs); const median = calculateMedian(runs); const p95 = calculatePercentile(runs, 95); @@ -648,7 +619,6 @@ function printResults(results: TestResult[]) { "═══════════════════════════════════════════════════════════════════\n" ); - // Overall averages const overallAverage = { lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length, fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length, @@ -696,7 +666,6 @@ function printResults(results: TestResult[]) { console.log("\n Optimization Opportunities:"); - // Find pages with highest JS bytes const highestJS = [...results].sort( (a, b) => b.median.jsBytes - a.median.jsBytes )[0]; @@ -707,7 +676,6 @@ function printResults(results: TestResult[]) { ); } - // Find pages with slow LCP const slowLCP = results.filter((r) => r.median.lcp > 2500); if (slowLCP.length > 0) { console.log( @@ -715,7 +683,6 @@ function printResults(results: TestResult[]) { ); } - // Find pages with high CLS const highCLS = results.filter((r) => r.median.cls > 0.1); if (highCLS.length > 0) { console.log( @@ -723,7 +690,6 @@ function printResults(results: TestResult[]) { ); } - // Find pages with high INP const highINP = results.filter((r) => r.median.inp > 200); if (highINP.length > 0) { console.log( @@ -740,7 +706,6 @@ async function main() { console.log(`Pages to test: ${TEST_PAGES.length}`); console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`); - // Check if server is running try { const response = await fetch(BASE_URL); if (!response.ok) { @@ -770,10 +735,8 @@ async function main() { await browser.close(); - // Print results printResults(results); - // Save results to JSON file const timestamp = new Date() .toISOString() .replace(/[:.]/g, "-") diff --git a/src/app.tsx b/src/app.tsx index 77b3111..edab7aa 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -35,10 +35,8 @@ function AppLayout(props: { children: any }) { let lastScrollY = 0; onMount(() => { - // Initialize performance tracking initPerformanceTracking(); - // Start monitoring for new deployments startDeploymentMonitoring(); const windowWidth = createWindowWidth(); diff --git a/src/components/Bars.tsx b/src/components/Bars.tsx index 470dded..b010194 100644 --- a/src/components/Bars.tsx +++ b/src/components/Bars.tsx @@ -460,7 +460,7 @@ function MainRightBarContent() { ); } -export function RightBarContent() { +function RightBarContent() { const site = useSite(); return ( }> diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx index 7969f2c..1a17894 100644 --- a/src/components/ContactForm.tsx +++ b/src/components/ContactForm.tsx @@ -144,7 +144,6 @@ const sendContactEmail = action(async (formData: FormData) => { const { env } = await import("~/env/server"); - // Verify Cloudflare Turnstile token const turnstileValid = await verifyTurnstileToken( turnstileToken, env.TURNSTILE_SECRET_KEY, @@ -252,7 +251,6 @@ export function ContactForm(props: ContactFormProps) { const [searchParams] = useSearchParams(); - // Load server data using createAsync const contactData = createAsync(() => getContactData(), { deferStream: true }); @@ -350,7 +348,6 @@ export function ContactForm(props: ContactFormProps) { const message = formData.get("message") as string; if (name && email && message) { - // Get fresh Turnstile token let currentToken = turnstileToken(); if ( !currentToken && @@ -388,7 +385,6 @@ export function ContactForm(props: ContactFormProps) { setError(""); form.reset(); - // Reset Turnstile widget if (typeof window !== "undefined" && (window as any).turnstile) { const widgetEl = document.getElementById("turnstile-widget-1"); if (widgetEl) { diff --git a/src/components/Typewriter.tsx b/src/components/Typewriter.tsx index 0d66e72..7ad7b8f 100644 --- a/src/components/Typewriter.tsx +++ b/src/components/Typewriter.tsx @@ -138,9 +138,6 @@ export function Typewriter(props: { entries.forEach((entry) => { // If component leaves viewport while animating, we could pause // For now, we just ensure it starts when visible - if (!entry.isIntersecting && cleanupAnimation) { - // Component is off-screen - could add pause logic here if needed - } }); }, { diff --git a/src/components/blog/AddAttachmentSection.tsx b/src/components/blog/AddAttachmentSection.tsx index a1d8f26..fbf5e49 100644 --- a/src/components/blog/AddAttachmentSection.tsx +++ b/src/components/blog/AddAttachmentSection.tsx @@ -63,7 +63,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) { }; reader.readAsDataURL(file); - // Refresh the S3 file list await loadAttachments(); } } catch (err) { @@ -81,7 +80,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) { body: JSON.stringify({ key }) }); - // Refresh the S3 file list await loadAttachments(); } catch (err) { console.error("Failed to delete file:", err); diff --git a/src/components/blog/CommentSectionWrapper.tsx b/src/components/blog/CommentSectionWrapper.tsx index 0868d1b..331b5a8 100644 --- a/src/components/blog/CommentSectionWrapper.tsx +++ b/src/components/blog/CommentSectionWrapper.tsx @@ -259,7 +259,6 @@ export default function CommentSectionWrapper( const newComment = async (commentBody: string, parentCommentID?: number) => { setCommentSubmitLoading(true); - // Clear any existing timeout if (commentSubmitTimeoutId) { clearTimeout(commentSubmitTimeoutId); } @@ -428,7 +427,6 @@ export default function CommentSectionWrapper( const editComment = async (body: string, comment_id: number) => { setCommentEditLoading(true); - // Clear any existing timeout if (editCommentTimeoutId) { clearTimeout(editCommentTimeoutId); } @@ -527,7 +525,6 @@ export default function CommentSectionWrapper( setCommentDeletionLoading(true); - // Clear any existing timeout if (deleteCommentTimeoutId) { clearTimeout(deleteCommentTimeoutId); } @@ -623,7 +620,6 @@ export default function CommentSectionWrapper( setOperationError(""); if (data.commentBody) { - // Soft delete (replace body with deletion message) setAllComments((prev) => prev.map((comment) => { if (comment.id === data.commentID) { @@ -652,7 +648,6 @@ export default function CommentSectionWrapper( }) ); } else { - // Hard delete (remove from list) setAllComments((prev) => prev.filter((comment) => comment.id !== data.commentID) ); @@ -667,7 +662,6 @@ export default function CommentSectionWrapper( }, 300); }; - // Deletion/edit prompt toggle const toggleModification = ( commentID: number, commenterID: string, @@ -708,7 +702,6 @@ export default function CommentSectionWrapper( setCommentBodyForModification(""); }; - // Reaction handling const commentReaction = (reactionType: ReactionType, commentID: number) => { if (!props.currentUserID) { console.warn("Cannot react to comment: user not authenticated"); @@ -800,7 +793,6 @@ export default function CommentSectionWrapper( } }; - // Click outside handlers (SolidJS version) createEffect(() => { const handleClickOutsideDelete = (e: MouseEvent) => { if ( diff --git a/src/components/blog/MermaidRenderer.tsx b/src/components/blog/MermaidRenderer.tsx index 7efced7..13a2ec5 100644 --- a/src/components/blog/MermaidRenderer.tsx +++ b/src/components/blog/MermaidRenderer.tsx @@ -8,12 +8,10 @@ function sanitizeMermaidSvg(svgString: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(svgString, "text/html"); - // Remove dangerous elements doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => { el.remove(); }); - // Remove event handlers and dangerous attributes from all elements doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => { const attrs = Array.from(el.attributes); attrs.forEach((attr) => { diff --git a/src/components/blog/PostBodyClient.tsx b/src/components/blog/PostBodyClient.tsx index d68efa5..28a3c53 100644 --- a/src/components/blog/PostBodyClient.tsx +++ b/src/components/blog/PostBodyClient.tsx @@ -11,7 +11,6 @@ function sanitizeHtml(html: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); - // Remove dangerous elements doc .querySelectorAll( "script, iframe, object, embed, form, link, meta, base, svg script" @@ -131,7 +130,6 @@ export default function PostBodyClient(props: PostBodyClientProps) { const processVideos = () => { if (!contentRef) return; - // Handle direct video elements const videoElements = contentRef.querySelectorAll("video"); videoElements.forEach((video) => { @@ -139,18 +137,14 @@ export default function PostBodyClient(props: PostBodyClientProps) { video.setAttribute("playsinline", ""); video.setAttribute("controls", ""); - // Remove download attribute if present video.removeAttribute("download"); - // Ensure proper MIME types on source elements const sources = video.querySelectorAll("source"); sources.forEach((source) => { const src = source.getAttribute("src"); if (src) { - // Remove download attribute from sources source.removeAttribute("download"); - // Set correct type attribute if missing if (!source.hasAttribute("type")) { if (src.endsWith(".mp4")) { source.setAttribute("type", "video/mp4"); @@ -163,7 +157,6 @@ export default function PostBodyClient(props: PostBodyClientProps) { } }); - // If video has direct src attribute, ensure type is set const videoSrc = video.getAttribute("src"); if (videoSrc && !video.hasAttribute("type")) { if (videoSrc.endsWith(".mp4")) { @@ -176,7 +169,6 @@ export default function PostBodyClient(props: PostBodyClientProps) { } }); - // Handle iframes with video sources - replace with proper video tags const iframes = contentRef.querySelectorAll("iframe"); iframes.forEach((iframe) => { const src = iframe.getAttribute("src"); @@ -187,7 +179,6 @@ export default function PostBodyClient(props: PostBodyClientProps) { src.endsWith(".webm") || src.endsWith(".ogg")) ) { - // Create a proper video element const video = document.createElement("video"); video.setAttribute("controls", ""); video.setAttribute("playsinline", ""); @@ -195,7 +186,6 @@ export default function PostBodyClient(props: PostBodyClientProps) { video.style.maxWidth = "100%"; video.style.height = "auto"; - // Set appropriate type based on file extension let videoType = "video/mp4"; if (src.endsWith(".mov")) { videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4 @@ -208,7 +198,6 @@ export default function PostBodyClient(props: PostBodyClientProps) { video.setAttribute("type", videoType); video.src = src; - // Replace the iframe with the video element const parent = iframe.parentElement; if (parent) { parent.replaceChild(video, iframe); @@ -216,7 +205,6 @@ export default function PostBodyClient(props: PostBodyClientProps) { } }); - // Also check for any anchor tags wrapping videos that might have download attribute const videoLinks = contentRef.querySelectorAll("a"); videoLinks.forEach((link) => { const hasVideo = link.querySelector("video"); diff --git a/src/components/blog/PostForm.tsx b/src/components/blog/PostForm.tsx index 5c11fb8..983159c 100644 --- a/src/components/blog/PostForm.tsx +++ b/src/components/blog/PostForm.tsx @@ -58,7 +58,6 @@ export default function PostForm(props: PostFormProps) { props.postId ); - // Mark initial load as complete after data is loaded (for edit mode) // Use setTimeout to ensure this runs after all signals are initialized createEffect(() => { if (props.mode === "edit" && props.initialData) { @@ -73,12 +72,10 @@ export default function PostForm(props: PostFormProps) { }, 5000); }; - // Helper to ensure post exists (create if needed) const ensurePostExists = async (): Promise => { const existingId = createdPostId() || props.postId; if (existingId) return existingId; - // Create minimal post if it doesn't exist yet const result = await api.database.createPost.mutate({ category: "blog", title: title().replaceAll(" ", "_") || "Untitled", @@ -95,7 +92,6 @@ export default function PostForm(props: PostFormProps) { return newId; }; - // Individual autosave functions for each field const autoSaveTitle = async () => { const currentTitle = title(); if (!currentTitle || currentTitle === props.initialData?.title) return; @@ -248,7 +244,6 @@ export default function PostForm(props: PostFormProps) { } }; - // Debounced versions const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500); const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500); const debouncedAutoSaveBody = debounce(autoSaveBody, 2500); @@ -256,7 +251,6 @@ export default function PostForm(props: PostFormProps) { const debouncedAutoSavePublished = debounce(autoSavePublished, 1000); const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500); - // Individual effects for each field createEffect(() => { const titleVal = title(); if (isInitialLoad()) return; @@ -405,7 +399,6 @@ export default function PostForm(props: PostFormProps) { author_id: props.userID }); } else { - // Create new post const result = await api.database.createPost.mutate({ category: "blog", title: title().replaceAll(" ", "_"), diff --git a/src/components/blog/PostSorting.tsx b/src/components/blog/PostSorting.tsx index eda3a02..5d63629 100644 --- a/src/components/blog/PostSorting.tsx +++ b/src/components/blog/PostSorting.tsx @@ -100,7 +100,7 @@ export default function PostSorting(props: PostSortingProps) { case "newest": break; // Posts already come newest first from DB (DESC order) case "oldest": - sorted.reverse(); // Reverse to get oldest first + sorted.reverse(); break; case "most_liked": sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0)); diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx index a1de8c5..40d1a63 100644 --- a/src/components/blog/TextEditor.tsx +++ b/src/components/blog/TextEditor.tsx @@ -1547,7 +1547,6 @@ export default function TextEditor(props: TextEditorProps) { }, handleDOMEvents: { touchstart: (view, event) => { - // Only handle touch events on mobile in fullscreen with active suggestion if ( !hasSuggestion() || !isFullscreen() || @@ -1562,7 +1561,6 @@ export default function TextEditor(props: TextEditorProps) { return false; }, touchend: (view, event) => { - // Only handle touch events on mobile in fullscreen with active suggestion if ( !hasSuggestion() || !isFullscreen() || @@ -1860,7 +1858,6 @@ export default function TextEditor(props: TextEditorProps) { const node = allSuperscriptNodes[i]; const text = node.text; - // Check if this is a complete reference (with optional whitespace) const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/); if (completeMatch) { const hasOtherMarks = node.marks.some( @@ -1877,7 +1874,6 @@ export default function TextEditor(props: TextEditorProps) { continue; } - // Check if this might be the start of a split reference if (text === "[" && i + 2 < allSuperscriptNodes.length) { const nextNode = allSuperscriptNodes[i + 1]; const afterNode = allSuperscriptNodes[i + 2]; @@ -1958,7 +1954,6 @@ export default function TextEditor(props: TextEditorProps) { allRefs.sort((a, b) => a.pos - b.pos); - // Check if renumbering is needed (if any ref doesn't match its expected number) let needsRenumbering = false; for (let i = 0; i < allRefs.length; i++) { if (allRefs[i].refNum !== i + 1) { diff --git a/src/components/blog/extensions/Mermaid.ts b/src/components/blog/extensions/Mermaid.ts index 3363e10..45ef7f1 100644 --- a/src/components/blog/extensions/Mermaid.ts +++ b/src/components/blog/extensions/Mermaid.ts @@ -69,7 +69,6 @@ export const Mermaid = Node.create({ getAttrs: (element) => { if (typeof element === "string") return false; - // Skip if already has data-type or data-mermaid-diagram attribute if ( element.hasAttribute("data-type") || element.hasAttribute("data-mermaid-diagram") @@ -83,7 +82,6 @@ export const Mermaid = Node.create({ const content = code.textContent || ""; const trimmedContent = content.trim(); - // Check if this looks like a mermaid diagram const mermaidKeywords = [ "graph ", "sequenceDiagram", @@ -174,12 +172,10 @@ export const Mermaid = Node.create({ code.textContent = node.attrs.content || ""; pre.appendChild(code); - // Validation status indicator const statusIndicator = document.createElement("div"); statusIndicator.className = "absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200"; - // Validate syntax asynchronously const validateSyntax = async () => { const content = node.attrs.content || ""; if (!content.trim()) { @@ -250,7 +246,6 @@ export const Mermaid = Node.create({ (p: any) => p.spec?.key === "mermaidSelection" ); - // Use intersection observer to trigger update when visible let updateInterval: ReturnType | null = null; const observer = new IntersectionObserver( (entries) => { diff --git a/src/components/icons/BackArrow.tsx b/src/components/icons/BackArrow.tsx deleted file mode 100644 index 41ace91..0000000 --- a/src/components/icons/BackArrow.tsx +++ /dev/null @@ -1,29 +0,0 @@ -const BackArrow = (props: { - height: number; - width: number; - stroke: string; - strokeWidth: number; - class?: string; -}) => { - return ( -
- - - -
- ); -}; - -export default BackArrow; diff --git a/src/components/icons/MenuBars.tsx b/src/components/icons/MenuBars.tsx deleted file mode 100644 index 5c03bf6..0000000 --- a/src/components/icons/MenuBars.tsx +++ /dev/null @@ -1,39 +0,0 @@ -function MenuBars() { - return ( - - - - - - - - - - ); -} - -export default MenuBars; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 2694d6c..277617e 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -128,7 +128,6 @@ export default function Button(props: ButtonProps) { height: number; } | null>(null); - // Measure content dimensions when not loading createEffect(() => { if (!local.loading && contentRef) { const rect = contentRef.getBoundingClientRect(); diff --git a/src/config.ts b/src/config.ts index 0995028..69837e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,6 @@ export const AUTH_CONFIG = { ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime - // Other Auth Settings CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, EMAIL_LOGIN_LINK_EXPIRY: "15m" as const, EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const, @@ -74,9 +73,6 @@ export const RATE_LIMITS = { EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 } } as const; -/** Rate limit store cleanup interval (5 minutes) */ -export const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; - // ============================================================ // ACCOUNT SECURITY // ============================================================ @@ -136,22 +132,6 @@ export const NETWORK_CONFIG = { RETRY_DELAY_MS: 1000 } as const; -// ============================================================ -// UI/UX - TYPEWRITER COMPONENT -// ============================================================ - -export const TYPEWRITER_CONFIG = { - DEFAULT_SPEED: 30, - FAST_SPEED: 80, - SLOW_SPEED: 10, - VERY_SLOW_SPEED: 100, - EXTRA_SLOW_SPEED: 120, - DEFAULT_KEEP_ALIVE_MS: 2000, - LONG_KEEP_ALIVE_MS: 10000, - DEFAULT_DELAY_MS: 500, - CURSOR_FADE_DELAY_MS: 1000 -} as const; - // ============================================================ // UI/UX - COUNTDOWN TIMER COMPONENT // ============================================================ @@ -177,41 +157,6 @@ export const BREAKPOINTS = { DESKTOP_MIN_WIDTH: 1025 } as const; -// ============================================================ -// UI/UX - ANIMATIONS & TRANSITIONS -// ============================================================ - -export const ANIMATION_CONFIG = { - TRANSITION_DURATION_MS: 300, - FAST_TRANSITION_MS: 200, - SLOW_TRANSITION_MS: 500, - EXTRA_SLOW_TRANSITION_MS: 600, - SIDEBAR_DURATION_MS: 500, - MENU_TYPING_DELAY_MS: 140, - MENU_INITIAL_DELAY_MS: 500, - SUCCESS_MESSAGE_DURATION_MS: 3000, - ERROR_MESSAGE_DURATION_MS: 5000, - REDIRECT_DELAY_MS: 500 -} as const; - -// ============================================================ -// UI/UX - PDF VIEWER -// ============================================================ - -export const PDF_CONFIG = { - RENDER_SCALE: 1.5 -} as const; - -// ============================================================ -// UI/UX - 401 ERROR PAGE -// ============================================================ - -export const ERROR_PAGE_CONFIG = { - GLITCH_INTERVAL_MS: 300, - GLITCH_DURATION_MS: 100, - PARTICLE_COUNT: 45 -} as const; - // ============================================================ // UI/UX - MOBILE CONFIG // ============================================================ @@ -284,22 +229,4 @@ export const LINEAGE_CONFIG = { JWT_AUDIENCE: "lineage-app" as const } as const; -// ============================================================ -// AUDIT & LOGGING -// ============================================================ -export const AUDIT_CONFIG = { - DEFAULT_QUERY_LIMIT: 100, - MAX_RETENTION_DAYS: 90 -} as const; - -// ============================================================ -// SESSION CLEANUP -// ============================================================ - -export const SESSION_CLEANUP_CONFIG = { - ENABLED: true, - INTERVAL_HOURS: 24, - RETENTION_DAYS: 90, - RUN_ON_STARTUP: true -} as const; diff --git a/src/context/SiteContext.tsx b/src/context/SiteContext.tsx index afc8db8..cc0a6b2 100644 --- a/src/context/SiteContext.tsx +++ b/src/context/SiteContext.tsx @@ -58,7 +58,7 @@ declare global { } /** Resolve the client-side active site, preferring the SSR-injected id. */ -export function resolveClientSite(): Site { +function resolveClientSite(): Site { if (typeof window === "undefined") return MAIN_SITE; const injected = window.__SITE__; if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected]; diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 4e302f4..c6f8e06 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -53,9 +53,7 @@ export const AuthProvider: ParentComponent = (props) => { // Get server state using createAsync which works with cache() const serverAuth = createAsync(() => getUserState(), { deferStream: true }); - // Refresh callback that forces re-fetch const refreshAuth = () => { - // Manually trigger a re-fetch by calling the revalidate function revalidate(["user-auth-state"]); }; @@ -70,7 +68,6 @@ export const AuthProvider: ParentComponent = (props) => { // Server handles all token refresh logic // Client just displays the current auth state from server - // Listen for auth refresh events from external sources (token refresh, etc.) onMount(() => { if (typeof window === "undefined") return; diff --git a/src/db/create.ts b/src/db/create.ts deleted file mode 100644 index 05dbd73..0000000 --- a/src/db/create.ts +++ /dev/null @@ -1,160 +0,0 @@ -export const model: { [key: string]: string } = { - User: ` - CREATE TABLE User - ( - id TEXT NOT NULL PRIMARY KEY, - email TEXT UNIQUE, - email_verified INTEGER DEFAULT 0, - password_hash TEXT, - display_name TEXT, - provider TEXT, - image TEXT, - is_admin INTEGER DEFAULT 0, - registered_at TEXT NOT NULL DEFAULT (datetime('now')), - failed_attempts INTEGER DEFAULT 0, - locked_until TEXT - ); - `, - UserProvider: ` - CREATE TABLE UserProvider - ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')), - provider_user_id TEXT, - email TEXT, - display_name TEXT, - image TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_used_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email); - CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id); - CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider); - CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email); - `, - PasswordResetToken: ` - CREATE TABLE PasswordResetToken - ( - id TEXT PRIMARY KEY, - token TEXT NOT NULL UNIQUE, - user_id TEXT NOT NULL, - expires_at TEXT NOT NULL, - used_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_password_reset_token ON PasswordResetToken (token); - CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON PasswordResetToken (user_id); - CREATE INDEX IF NOT EXISTS idx_password_reset_expires_at ON PasswordResetToken (expires_at); - `, - Post: ` - CREATE TABLE Post - ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL UNIQUE, - subtitle TEXT, - body TEXT NOT NULL, - banner_photo TEXT, - date TEXT, - published INTEGER NOT NULL, - category TEXT, - author_id TEXT NOT NULL, - reads INTEGER NOT NULL DEFAULT 0, - attachments TEXT, - last_edited_date TEXT - ); - CREATE INDEX IF NOT EXISTS idx_posts_category ON Post (category); - CREATE INDEX IF NOT EXISTS idx_posts_published ON Post (published); - CREATE INDEX IF NOT EXISTS idx_posts_date ON Post (date); - CREATE INDEX IF NOT EXISTS idx_posts_published_date ON Post (published, date); - `, - PostLike: ` - CREATE TABLE PostLike - ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - post_id INTEGER NOT NULL - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_likes_user_post ON PostLike (user_id, post_id); - CREATE INDEX IF NOT EXISTS idx_likes_post_id ON PostLike (post_id); - `, - Comment: ` - CREATE TABLE Comment - ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - body TEXT NOT NULL, - post_id INTEGER, - parent_comment_id INTEGER, - date TEXT NOT NULL DEFAULT (datetime('now')), - edited INTEGER NOT NULL DEFAULT 0, - commenter_id TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_comment_commenter_id ON Comment (commenter_id); - CREATE INDEX IF NOT EXISTS idx_comment_parent_comment_id ON Comment (parent_comment_id); - CREATE INDEX IF NOT EXISTS idx_comment_post_id ON Comment (post_id); - `, - CommentReaction: ` - CREATE TABLE CommentReaction - ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - comment_id INTEGER NOT NULL, - user_id TEXT NOT NULL - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_reaction_user_type_comment ON CommentReaction (user_id, type, comment_id); - `, - Connection: ` - CREATE TABLE Connection - ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_id TEXT NOT NULL, - post_id INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_connection_post_id ON Connection (post_id); - `, - Tag: ` - CREATE TABLE Tag - ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - value TEXT NOT NULL, - post_id INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_tag_post_id ON Tag (post_id); - CREATE INDEX IF NOT EXISTS idx_tag_value ON Tag (value); - CREATE INDEX IF NOT EXISTS idx_tag_post_value ON Tag (post_id, value); - `, - PostHistory: ` - CREATE TABLE PostHistory - ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - post_id INTEGER NOT NULL, - parent_id INTEGER, - content TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - is_saved INTEGER DEFAULT 0, - FOREIGN KEY (post_id) REFERENCES Post(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_history_post_id ON PostHistory (post_id); - CREATE INDEX IF NOT EXISTS idx_history_parent_id ON PostHistory (parent_id); - `, - RateLimit: ` - CREATE TABLE RateLimit - ( - id TEXT PRIMARY KEY, - identifier TEXT NOT NULL, - count INTEGER NOT NULL DEFAULT 1, - reset_at TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - -- Unique constraint on identifier so ON CONFLICT(identifier) atomic upserts - -- (see src/server/security.ts checkRateLimit) are well-defined. This makes - -- the rate-limit state shared across all instances (p8-010). - CREATE UNIQUE INDEX IF NOT EXISTS idx_ratelimit_identifier_unique ON RateLimit (identifier); - CREATE INDEX IF NOT EXISTS idx_ratelimit_reset_at ON RateLimit (reset_at); - ` -}; diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 813554b..7018c87 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -34,14 +34,12 @@ function shouldAttemptReload(): boolean { 10 ); - // Reset counter if outside the time window if (now - lastReloadTime > RELOAD_WINDOW_MS) { sessionStorage.setItem(RELOAD_STORAGE_KEY, "0"); sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString()); return true; } - // Check if we've exceeded max reloads if (reloadCount >= MAX_RELOADS) { console.error( `Exceeded ${MAX_RELOADS} reload attempts in ${RELOAD_WINDOW_MS}ms. Stopping to prevent infinite loop.` @@ -49,12 +47,10 @@ function shouldAttemptReload(): boolean { return false; } - // Increment counter and allow reload sessionStorage.setItem(RELOAD_STORAGE_KEY, (reloadCount + 1).toString()); sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString()); return true; } catch (e) { - // If sessionStorage fails, allow reload but log error console.warn("Failed to access sessionStorage:", e); return true; } @@ -102,7 +98,6 @@ function handleChunkError(source: string): void { } } -// Handle runtime chunk loading errors window.addEventListener("error", (event) => { if ( event.message?.includes("Importing a module script failed") || @@ -113,7 +108,6 @@ window.addEventListener("error", (event) => { } }); -// Handle promise-based chunk loading errors window.addEventListener("unhandledrejection", (event) => { if ( event.reason?.message?.includes("Importing a module script failed") || @@ -126,9 +120,7 @@ window.addEventListener("unhandledrejection", (event) => { } }); -// Clear reload counter on successful page load window.addEventListener("load", () => { - // Only clear if we successfully loaded (we're past the critical chunk loading phase) setTimeout(() => { sessionStorage.removeItem(RELOAD_STORAGE_KEY); sessionStorage.removeItem(RELOAD_TIMESTAMP_KEY); diff --git a/src/env/client.ts b/src/env/client.ts index ed6ec6c..f0c6569 100644 --- a/src/env/client.ts +++ b/src/env/client.ts @@ -38,16 +38,7 @@ export const validateClientEnv = ( return envVars as unknown as ClientEnv; }; -const validateAndExportEnv = (): ClientEnv => { - try { - const validated = validateClientEnv(import.meta.env); - return validated; - } catch (error) { - throw error; - } -}; - -export const env = validateAndExportEnv(); +export const env = validateClientEnv(import.meta.env); export const isMissingEnvVar = (varName: string): boolean => { return !import.meta.env[varName] || import.meta.env[varName]?.trim() === ""; diff --git a/src/lib/auth-query.ts b/src/lib/auth-query.ts index 4fce8e0..3d7076d 100644 --- a/src/lib/auth-query.ts +++ b/src/lib/auth-query.ts @@ -80,7 +80,6 @@ export const getUserState = query(async (): Promise => { * Call this after login, logout, token refresh, email verification */ export function revalidateAuth() { - // Revalidate the cache revalidateKey("user-auth-state"); // Dispatch event to trigger UI updates (client-side only) diff --git a/src/lib/client-utils.ts b/src/lib/client-utils.ts index db67207..0d57b7c 100644 --- a/src/lib/client-utils.ts +++ b/src/lib/client-utils.ts @@ -3,21 +3,6 @@ * Note: These utilities should only run in the browser */ -/** - * Fetch wrapper for auth checks where 401s are expected and should not trigger console errors - */ -export async function safeFetch( - input: RequestInfo | URL, - init?: RequestInit -): Promise { - try { - const response = await fetch(input, init); - return response; - } catch (error) { - throw error; - } -} - /** * Decode JWT payload without verification (client-side only) * @param token - JWT token string diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts deleted file mode 100644 index 906b699..0000000 --- a/src/lib/cookies.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Cookie utilities for SolidStart - * Provides client and server-side cookie management - */ - -import { getCookie as getServerCookie, setCookie as setServerCookie } from "vinxi/http"; -import type { H3Event } from "vinxi/http"; - -/** - * Get cookie value on the server - */ -export function getCookie(event: H3Event, name: string): string | undefined { - return getServerCookie(event, name); -} - -/** - * Set cookie on the server - */ -export function setCookie( - event: H3Event, - name: string, - value: string, - options?: { - maxAge?: number; - expires?: Date; - httpOnly?: boolean; - secure?: boolean; - sameSite?: "strict" | "lax" | "none"; - path?: string; - } -) { - setServerCookie(event, name, value, options); -} - -/** - * Delete cookie on the server - */ -export function deleteCookie(event: H3Event, name: string) { - setServerCookie(event, name, "", { - maxAge: 0, - expires: new Date("2016-10-05"), - }); -} - -/** - * Get cookie value on the client (browser) - */ -export function getClientCookie(name: string): string | undefined { - if (typeof document === "undefined") return undefined; - - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - - if (parts.length === 2) { - return parts.pop()?.split(";").shift(); - } - - return undefined; -} - -/** - * Set cookie on the client (browser) - */ -export function setClientCookie( - name: string, - value: string, - options?: { - maxAge?: number; - expires?: Date; - path?: string; - secure?: boolean; - sameSite?: "strict" | "lax" | "none"; - } -) { - if (typeof document === "undefined") return; - - let cookieString = `${name}=${value}`; - - if (options?.maxAge) { - cookieString += `; max-age=${options.maxAge}`; - } - - if (options?.expires) { - cookieString += `; expires=${options.expires.toUTCString()}`; - } - - if (options?.path) { - cookieString += `; path=${options.path}`; - } else { - cookieString += "; path=/"; - } - - if (options?.secure) { - cookieString += "; secure"; - } - - if (options?.sameSite) { - cookieString += `; samesite=${options.sameSite}`; - } - - document.cookie = cookieString; -} - -/** - * Delete cookie on the client (browser) - */ -export function deleteClientCookie(name: string) { - if (typeof document === "undefined") return; - - document.cookie = `${name}=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; -} diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index 43a2194..57f18e5 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -81,7 +81,6 @@ export function formatRelativeTime( return `${diffDay}d ago`; } } else { - // style === "long" if (includeSeconds && diffSec < 60) { return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`; } diff --git a/src/lib/deployment-detection.ts b/src/lib/deployment-detection.ts index c946853..7378993 100644 --- a/src/lib/deployment-detection.ts +++ b/src/lib/deployment-detection.ts @@ -12,14 +12,12 @@ const VERSION_STORAGE_KEY = "app-version-hash"; */ function getCurrentVersionHash(): string { try { - // Use a combination of script tags to detect version const scripts = Array.from(document.querySelectorAll("script[src]")) .map((s) => (s as HTMLScriptElement).src) .filter((src) => src.includes("/_build/")) .sort() .join(","); - // Simple hash function let hash = 0; for (let i = 0; i < scripts.length; i++) { const char = scripts.charCodeAt(i); @@ -39,7 +37,6 @@ function getCurrentVersionHash(): string { */ async function checkForNewVersion(): Promise { try { - // Fetch current page HTML const response = await fetch(window.location.pathname, { method: "HEAD", cache: "no-cache" @@ -64,7 +61,6 @@ async function checkForNewVersion(): Promise { return true; } - // Store current ETag for future checks if (newEtag) { sessionStorage.setItem("app-etag", newEtag); } @@ -80,7 +76,6 @@ async function checkForNewVersion(): Promise { * Show update notification to user */ function showUpdateNotification(): void { - // Only show once per session if (sessionStorage.getItem("update-notification-shown")) { return; } @@ -147,7 +142,6 @@ function showUpdateNotification(): void { document.body.appendChild(notification); - // Auto-remove after 30 seconds setTimeout(() => { if (notification.parentElement) { notification.style.animation = "slideIn 0.3s ease-out reverse"; @@ -162,11 +156,9 @@ function showUpdateNotification(): void { export function startDeploymentMonitoring(): void { if (typeof window === "undefined") return; - // Store initial version const initialVersion = getCurrentVersionHash(); sessionStorage.setItem(VERSION_STORAGE_KEY, initialVersion); - // Periodic version check const intervalId = setInterval(async () => { const hasNewVersion = await checkForNewVersion(); if (hasNewVersion) { @@ -174,7 +166,6 @@ export function startDeploymentMonitoring(): void { } }, VERSION_CHECK_INTERVAL); - // Check on visibility change (user returns to tab) const handleVisibilityChange = async () => { if (document.visibilityState === "visible") { const hasNewVersion = await checkForNewVersion(); @@ -186,7 +177,6 @@ export function startDeploymentMonitoring(): void { document.addEventListener("visibilitychange", handleVisibilityChange); - // Cleanup function if (typeof window !== "undefined") { (window as any).__cleanupDeploymentMonitoring = () => { clearInterval(intervalId); diff --git a/src/lib/performance-tracking.ts b/src/lib/performance-tracking.ts index e2ee102..91ce00f 100644 --- a/src/lib/performance-tracking.ts +++ b/src/lib/performance-tracking.ts @@ -26,7 +26,6 @@ export function initPerformanceTracking() { const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []); - // Observe LCP if (supported.has("largest-contentful-paint")) { try { const lcpObserver = new PerformanceObserver((entryList) => { @@ -40,7 +39,6 @@ export function initPerformanceTracking() { } } - // Observe CLS if (supported.has("layout-shift")) { try { const clsObserver = new PerformanceObserver((entryList) => { @@ -59,7 +57,6 @@ export function initPerformanceTracking() { } } - // Observe FID if (supported.has("first-input")) { try { const fidObserver = new PerformanceObserver((entryList) => { @@ -74,7 +71,6 @@ export function initPerformanceTracking() { } } - // Observe INP (event timing) if (supported.has("event")) { try { const interactions: number[] = []; @@ -96,7 +92,6 @@ export function initPerformanceTracking() { } } - // Get navigation timing metrics window.addEventListener("load", () => { setTimeout(() => { const navTiming = performance.getEntriesByType( @@ -110,7 +105,6 @@ export function initPerformanceTracking() { metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart; } - // Get FCP const paintEntries = performance.getEntriesByType("paint"); const fcpEntry = paintEntries.find( (entry) => entry.name === "first-contentful-paint" @@ -135,7 +129,6 @@ export function initPerformanceTracking() { } function sendMetrics() { - // Only send if we have at least one metric if (Object.keys(metrics).length === 0) { return; } @@ -157,7 +150,6 @@ function sendMetrics() { const blob = new Blob([payload], { type: "application/json" }); navigator.sendBeacon(apiUrl, blob); } else { - // Fallback to fetch with keepalive fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -168,6 +160,5 @@ function sendMetrics() { ); } - // Clear metrics after sending metrics = {}; } diff --git a/src/lib/s3upload.ts b/src/lib/s3upload.ts index 37c67fd..82290ca 100644 --- a/src/lib/s3upload.ts +++ b/src/lib/s3upload.ts @@ -61,7 +61,6 @@ export default async function AddImageToS3( throw new Error("Failed to upload file to S3"); } - // Create thumbnails for images (blog posts only) if (type === "blog" && isImage) { try { const thumbnail = await resizeImage(file, 200, 200, 0.8); diff --git a/src/lib/sitemap-generate.ts b/src/lib/sitemap-generate.ts index 38d65da..d597f3a 100644 --- a/src/lib/sitemap-generate.ts +++ b/src/lib/sitemap-generate.ts @@ -22,7 +22,7 @@ function xmlEscape(s: string): string { /** * Generate a single `` element for a given entry on a site. */ -export function urlElement(site: Site, entry: SitemapEntry): string { +function urlElement(site: Site, entry: SitemapEntry): string { const loc = `https://${site.domain}${entry.path}`; return ` ${xmlEscape(loc)} diff --git a/src/lib/sitemap-routes.test.ts b/src/lib/sitemap-routes.test.ts index 0d08b45..1b7ff3e 100644 --- a/src/lib/sitemap-routes.test.ts +++ b/src/lib/sitemap-routes.test.ts @@ -28,13 +28,11 @@ describe("generateSitemap", () => { it("generates valid XML for main site with all expected routes", () => { const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main); - // Basic structure expect(xml).toContain(''); expect(xml).toContain( '' ); - // All main site paths present with freno.me domain const locs = extractLocs(xml); expect(locs).toContain("https://freno.me/"); expect(locs).toContain("https://freno.me/blog"); @@ -43,10 +41,8 @@ describe("generateSitemap", () => { expect(locs).toContain("https://freno.me/resume"); expect(locs).toContain("https://freno.me/downloads"); - // Exactly 6 entries expect(locs.length).toBe(6); - // Verify well-formedness by checking balanced tags expect(xml).toContain(""); const urlOpens = (xml.match(//g) || []).length; const urlCloses = (xml.match(/<\/url>/g) || []).length; @@ -56,7 +52,6 @@ describe("generateSitemap", () => { it("generates valid parseable XML for lineage site", () => { const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage); - // Verify balanced tags expect(xml).toContain(""); const urlOpens = (xml.match(//g) || []).length; const urlCloses = (xml.match(/<\/url>/g) || []).length; @@ -73,7 +68,6 @@ describe("generateSitemap", () => { expect(locs).toContain("https://nessa.freno.me/privacy"); expect(locs.length).toBe(3); - // No leakage from main site for (const loc of locs) { expect(loc).not.toContain("://freno.me/"); expect(loc).not.toContain("://freno.me/blog"); diff --git a/src/lib/useCountdown.ts b/src/lib/useCountdown.ts index 28c1f82..e231267 100644 --- a/src/lib/useCountdown.ts +++ b/src/lib/useCountdown.ts @@ -45,15 +45,12 @@ export function useCountdown(options: UseCountdownOptions = {}) { }; const startCountdown = (expiresAt: string | Date) => { - // Clear any existing interval if (intervalId !== null) { clearInterval(intervalId); } - // Calculate immediately calculateRemaining(expiresAt); - // Then update every second intervalId = setInterval(() => calculateRemaining(expiresAt), 1000); }; @@ -64,7 +61,6 @@ export function useCountdown(options: UseCountdownOptions = {}) { } }; - // Cleanup on unmount onCleanup(() => { stopCountdown(); }); diff --git a/src/routes/account.tsx b/src/routes/account.tsx index 0e4dc9b..7dc56ae 100644 --- a/src/routes/account.tsx +++ b/src/routes/account.tsx @@ -512,501 +512,95 @@ export default function AccountPage() { Account Settings - {/* Account Type Section */} -
-
-
- Account Type -
-
- - - - - - - - - - - - - {getProviderName(userProfile().provider)} Account - -
- -
- ⚠️ Add an email address for account recovery -
-
- -
- {!userProfile().email - ? "💡 Add and verify an email to enable email/password login" - : !userProfile().emailVerified - ? "💡 Verify your email to enable password setup" - : "💡 Add a password to enable email/password login"} -
-
-
-
+
- {/* Profile Image Section */} -
-
-
- Profile Image -
- -
- - -
-
- -
- -
-
+
{/* Email Section */}
-
-
-
- {userProfile().provider === "email" - ? "Email:" - : "Linked Email:"} -
- {userProfile().email ? ( - {userProfile().email} - ) : ( - - {userProfile().provider === "email" - ? "None Set" - : "Not Linked"} - - )} -
- - - -
- -
- - - -
- Add an email for account recovery and notifications -
-
-
- -
- - + (emailRef = el)} + emailButtonLoading={emailButtonLoading} + setEmailTrigger={setEmailTrigger} + showEmailSuccess={showEmailSuccess} + sendEmailVerification={sendEmailVerification} + /> {/* Display Name Section */} -
-
-
- Display Name: -
- {userProfile().displayName ? ( - {userProfile().displayName} - ) : ( - - None Set - - )} -
-
- -
- - -
- -
- - + (displayNameRef = el)} + displayNameButtonLoading={displayNameButtonLoading} + setDisplayNameTrigger={setDisplayNameTrigger} + showDisplayNameSuccess={showDisplayNameSuccess} + />
- {/* Password Change/Set Section */} -
-
-
- {userProfile().hasPassword - ? "Change Password" - : "Add Password"} -
- - - -
-
- ⚠️ Email Verification Required -
-
- {!userProfile().email - ? "Please add and verify an email address before setting a password." - : "Please verify your email address before setting a password."} -
- - - -
-
- -
- {userProfile().provider === "email" - ? "Set a password to enable password login" - : "Add a password to enable email/password login alongside your " + - getProviderName(userProfile().provider) + - " login"} -
-
-
- - - - - - - - - = 6 - } - > - - - - - - - - -
-
+ (oldPasswordRef = el)} + newPasswordRef={(el) => (newPasswordRef = el)} + newPasswordConfRef={(el) => + el !== undefined + ? (newPasswordConfRef = el) + : newPasswordConfRef + } + handleNewPasswordChange={handleNewPasswordChange} + handlePasswordConfChange={handlePasswordConfChange} + handlePasswordBlur={handlePasswordBlur} + sendEmailVerification={sendEmailVerification} + getProviderName={getProviderName} + passwordChangeLoading={passwordChangeLoading} + newPassword={newPassword} + passwordsMatch={passwordsMatch} + passwordLengthSufficient={passwordLengthSufficient} + passwordError={passwordError} + showPasswordSuccess={showPasswordSuccess} + />
- {/* Linked Providers Section */} -
-
- Linked Authentication Methods -
-
- -
-
+
- {/* Sign Out Section */} -
- -
+
- {/* Delete Account Section */} -
-
-
- Delete Account -
-
- Warning: This will delete all account information and is - irreversible -
- - - - -
- Your {getProviderName(userProfile().provider)}{" "} - account doesn't have a password. To delete your - account, please set a password first, then return - here to proceed with deletion. -
- -
- } - > -
-
- -
- - - - - - -
- + + (deleteAccountPasswordRef = el) + } + deleteAccountButtonLoading={deleteAccountButtonLoading} + deleteAccountTrigger={deleteAccountTrigger} + passwordDeletionError={passwordDeletionError} + /> )}
@@ -1016,6 +610,635 @@ export default function AccountPage() { ); } +function AccountTypeSection(props: { + profile: () => UserProfile; + getProviderColor: (provider: UserProfile["provider"]) => string; + getProviderName: (provider: UserProfile["provider"]) => string; +}) { + const { profile, getProviderColor, getProviderName } = props; + + return ( +
+
+
+ Account Type +
+
+ + + + + + + + + + + + + {getProviderName(profile().provider)} Account + +
+ +
+ ⚠️ Add an email address for account recovery +
+
+ +
+ {!profile().email + ? "💡 Add and verify an email to enable email/password login" + : !profile().emailVerified + ? "💡 Verify your email to enable password setup" + : "💡 Add a password to enable email/password login"} +
+
+
+
+ ); +} + +function ProfileImageSection(props: { + profile: () => UserProfile; + handleImageDrop: (acceptedFiles: File[]) => void; + profileImageHolder: () => string | null; + preSetHolder: () => string | null; + removeImage: () => void; + setUserImage: (e: Event) => void; + profileImageSetLoading: () => boolean; + profileImageStateChange: () => boolean; + showImageSuccess: () => boolean; +}) { + const { + profile, + handleImageDrop, + profileImageHolder, + preSetHolder, + removeImage, + setUserImage, + profileImageSetLoading, + profileImageStateChange, + showImageSuccess + } = props; + + return ( +
+
+
+ Profile Image +
+ +
+ + +
+
+ +
+ +
+
+ ); +} + +function EmailSection(props: { + profile: () => UserProfile; + emailRef: (el: HTMLInputElement) => void; + emailButtonLoading: () => boolean; + setEmailTrigger: (e: Event) => void; + showEmailSuccess: () => boolean; + sendEmailVerification: () => void; +}) { + const { + profile, + emailRef, + emailButtonLoading, + setEmailTrigger, + showEmailSuccess, + sendEmailVerification + } = props; + + return ( + <> +
+
+
+ {profile().provider === "email" + ? "Email:" + : "Linked Email:"} +
+ {profile().email ? ( + {profile().email} + ) : ( + + {profile().provider === "email" + ? "None Set" + : "Not Linked"} + + )} +
+ + + +
+ +
+ + + +
+ Add an email for account recovery and notifications +
+
+
+ +
+ + + + ); +} + +function DisplayNameSection(props: { + profile: () => UserProfile; + displayNameRef: (el: HTMLInputElement) => void; + displayNameButtonLoading: () => boolean; + setDisplayNameTrigger: (e: Event) => void; + showDisplayNameSuccess: () => boolean; +}) { + const { + profile, + displayNameRef, + displayNameButtonLoading, + setDisplayNameTrigger, + showDisplayNameSuccess + } = props; + + return ( + <> +
+
+
+ Display Name: +
+ {profile().displayName ? ( + {profile().displayName} + ) : ( + + None Set + + )} +
+
+ +
+ + +
+ +
+ + + + ); +} + +function PasswordSection(props: { + profile: () => UserProfile; + handlePasswordSubmit: (e: Event) => void; + oldPasswordRef: (el: HTMLInputElement) => void; + newPasswordRef: (el: HTMLInputElement) => void; + newPasswordConfRef: ( + el?: HTMLInputElement + ) => HTMLInputElement | undefined; + handleNewPasswordChange: (e: Event) => void; + handlePasswordConfChange: (e: Event) => void; + handlePasswordBlur: () => void; + sendEmailVerification: () => void; + getProviderName: (provider: UserProfile["provider"]) => string; + passwordChangeLoading: () => boolean; + newPassword: () => string; + passwordsMatch: () => boolean; + passwordLengthSufficient: () => boolean; + passwordError: () => boolean; + showPasswordSuccess: () => boolean; +}) { + const { + profile, + handlePasswordSubmit, + oldPasswordRef, + newPasswordRef, + newPasswordConfRef, + handleNewPasswordChange, + handlePasswordConfChange, + handlePasswordBlur, + sendEmailVerification, + getProviderName, + passwordChangeLoading, + newPassword, + passwordsMatch, + passwordLengthSufficient, + passwordError, + showPasswordSuccess + } = props; + + return ( +
+
+
+ {profile().hasPassword + ? "Change Password" + : "Add Password"} +
+ + + +
+
+ ⚠️ Email Verification Required +
+
+ {!profile().email + ? "Please add and verify an email address before setting a password." + : "Please verify your email address before setting a password."} +
+ + + +
+
+ +
+ {profile().provider === "email" + ? "Set a password to enable password login" + : "Add a password to enable email/password login alongside your " + + getProviderName(profile().provider) + + " login"} +
+
+
+ + + + + + + + + = 6 + } + > + + + + + + + + +
+
+ ); +} + +function LinkedProvidersSection(props: { profile: () => UserProfile }) { + const { profile } = props; + + return ( +
+
+ Linked Authentication Methods +
+
+ +
+
+ ); +} + +function SignOutSection(props: { + handleSignOut: () => void; + signOutLoading: () => boolean; +}) { + const { handleSignOut, signOutLoading } = props; + + return ( +
+ +
+ ); +} + +function DeleteAccountSection(props: { + profile: () => UserProfile; + getProviderName: (provider: UserProfile["provider"]) => string; + deleteAccountPasswordRef: (el: HTMLInputElement) => void; + deleteAccountButtonLoading: () => boolean; + deleteAccountTrigger: (e: Event) => void; + passwordDeletionError: () => boolean; +}) { + const { + profile, + getProviderName, + deleteAccountPasswordRef, + deleteAccountButtonLoading, + deleteAccountTrigger, + passwordDeletionError + } = props; + + return ( +
+
+
+ Delete Account +
+
+ Warning: This will delete all account information and is + irreversible +
+ + + + +
+ Your {getProviderName(profile().provider)}{" "} + account doesn't have a password. To delete your + account, please set a password first, then return + here to proceed with deletion. +
+ +
+ } + > +
+
+ +
+ + + + + + +
+ + ); +} + function LinkedProviders(props: { userId: string }) { const [providers, setProviders] = createSignal([]); const [loading, setLoading] = createSignal(true); diff --git a/src/routes/api/Gaze/appcast.xml.ts b/src/routes/api/Gaze/appcast.xml.ts index 2801db1..d946104 100644 --- a/src/routes/api/Gaze/appcast.xml.ts +++ b/src/routes/api/Gaze/appcast.xml.ts @@ -39,7 +39,6 @@ export async function GET(_event: APIEvent) { }); } - // Stream the XML content from S3 const body = await response.Body.transformToString(); return new Response(body, { diff --git a/src/routes/api/InputHalo/appcast.xml.ts b/src/routes/api/InputHalo/appcast.xml.ts index 1ed049f..90d47ab 100644 --- a/src/routes/api/InputHalo/appcast.xml.ts +++ b/src/routes/api/InputHalo/appcast.xml.ts @@ -39,7 +39,6 @@ export async function GET(_event: APIEvent) { }); } - // Stream the XML content from S3 const body = await response.Body.transformToString(); return new Response(body, { diff --git a/src/routes/api/auth/email-login-callback.ts b/src/routes/api/auth/email-login-callback.ts index faadc63..7776d48 100644 --- a/src/routes/api/auth/email-login-callback.ts +++ b/src/routes/api/auth/email-login-callback.ts @@ -20,7 +20,6 @@ export async function GET(event: APIEvent) { "emailLogin", (caller, params) => caller.auth.emailLogin(params), (error) => { - // Check for token expiration const message = error instanceof Error ? error.message : ""; const isTokenError = message.includes("expired") || message.includes("invalid"); diff --git a/src/routes/api/auth/email-verification-callback.ts b/src/routes/api/auth/email-verification-callback.ts index f75801a..58f9c4c 100644 --- a/src/routes/api/auth/email-verification-callback.ts +++ b/src/routes/api/auth/email-verification-callback.ts @@ -62,17 +62,14 @@ export async function GET(event: APIEvent) { } try { - // Create tRPC caller to invoke the emailVerification procedure const caller = await createServerCaller(event); - // Call the email verification handler const result = await caller.auth.emailVerification({ email, token }); if (result.success) { - // Show success page return new Response( ` @@ -136,7 +133,6 @@ export async function GET(event: APIEvent) { } catch (error) { console.error("Email verification callback error:", error); - // Check if it's a token expiration error const errorMessage = error instanceof Error ? error.message : "server_error"; const isTokenError = diff --git a/src/routes/api/downloads/[filename].ts b/src/routes/api/downloads/[filename].ts index 69d51b7..967ba39 100644 --- a/src/routes/api/downloads/[filename].ts +++ b/src/routes/api/downloads/[filename].ts @@ -24,7 +24,6 @@ export async function GET(event: APIEvent) { }); } - // Validate filename format (only allow Gaze or InputHalo files) const validPrefixes = ["Gaze", "InputHalo"]; const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix)); if ( @@ -70,12 +69,10 @@ export async function GET(event: APIEvent) { }); } - // Get content type based on file extension const contentType = filename.endsWith(".dmg") ? "application/x-apple-diskimage" : "application/octet-stream"; - // Stream the file content from S3 const body = await response.Body.transformToByteArray(); console.log(`✓ Serving ${filename} (${body.length} bytes)`); @@ -93,7 +90,6 @@ export async function GET(event: APIEvent) { } catch (error) { console.error(`Failed to fetch ${filename} from S3:`, error); - // Check if it's a not found error if (error instanceof Error && error.name === "NoSuchKey") { return new Response("File not found in storage", { status: 404, diff --git a/src/routes/api/lineage/_lib.ts b/src/routes/api/lineage/_lib.ts index 5c2093c..71e62ce 100644 --- a/src/routes/api/lineage/_lib.ts +++ b/src/routes/api/lineage/_lib.ts @@ -58,8 +58,3 @@ export function bearerToken(event: APIEvent): string | null { const m = auth.match(/^Bearer\s+(.+)$/i); return m?.[1]?.trim() ?? null; } - -/** Parse the JSON request body. */ -export async function jsonBody(event: APIEvent): Promise { - return await event.request.json(); -} diff --git a/src/routes/downloads.tsx b/src/routes/downloads.tsx index 67f3e17..6da32ea 100644 --- a/src/routes/downloads.tsx +++ b/src/routes/downloads.tsx @@ -17,7 +17,6 @@ function MainDownloadsPage() { const [gazeText, setGazeText] = createSignal("Gaze"); const [inputHaloText, setInputHaloText] = createSignal("InputHalo"); - // Track loading states for each download button const [loadingState, setLoadingState] = createSignal>( { lineage: false, @@ -32,10 +31,8 @@ function MainDownloadsPage() { // Prevent multiple rapid clicks if (loadingState()[assetName]) return; - // Set loading state setLoadingState((prev) => ({ ...prev, [assetName]: true })); - // Call the tRPC endpoint directly import("~/lib/api").then(({ api }) => { api.downloads.getDownloadUrl .query({ asset_name: assetName }) @@ -45,11 +42,9 @@ function MainDownloadsPage() { }) .catch((error) => { console.error("Download error:", error); - // Optionally show user a message alert("Failed to initiate download. Please try again."); }) .finally(() => { - // Reset loading state regardless of success/failure setLoadingState((prev) => ({ ...prev, [assetName]: false })); }); }); diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index ebc9ea4..351a252 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -57,7 +57,6 @@ export const route = { load: () => checkAuth() }; -// Helper to convert expiry string to human-readable format function expiryToHuman(expiry: string): string { const value = parseInt(expiry); if (expiry.endsWith("m")) { @@ -77,7 +76,6 @@ export default function LoginPage() { const register = () => searchParams.mode === "register"; const usePassword = () => searchParams.auth === "password"; - // Load server data using createAsync const loginData = createAsync(() => getLoginData(), { deferStream: true }); @@ -149,6 +147,180 @@ export default function LoginPage() { } }); + const isRateLimited = (errorCode: string | undefined, message: string) => + errorCode === "TOO_MANY_REQUESTS" || message.includes("Too many attempts"); + + const submitRegister = async () => { + if (!emailRef || !passwordRef || !passwordConfRef) { + setError("Please fill in all fields"); + return; + } + + const email = emailRef.value; + const password = passwordRef.value; + const passwordConf = passwordConfRef.value; + + if (!isValidEmail(email)) { + setError("Invalid email address"); + return; + } + + const passwordValidation = validatePassword(password); + if (!passwordValidation.isValid) { + setError(passwordValidation.errors[0] || "Invalid password"); + return; + } + + if (password !== passwordConf) { + setError("passwordMismatch"); + return; + } + + const response = await fetch("/api/trpc/auth.emailRegistration", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password, + passwordConfirmation: passwordConf + }) + }); + + const result = await response.json(); + + if (response.ok && result.result?.data) { + navigate("/account", { replace: true }); + return; + } + + const errorMsg = + result.error?.message || + result.result?.data?.message || + "Registration failed"; + const errorCode = result.error?.data?.code; + + if (isRateLimited(errorCode, errorMsg)) { + setError(errorMsg); + } else if ( + errorMsg.includes("duplicate") || + errorMsg.includes("already exists") + ) { + if (errorMsg.includes("sign in and add a password")) { + setError("provider_exists"); + } else { + setError("duplicate"); + } + } else { + setError(errorMsg); + } + }; + + const submitPasswordLogin = async () => { + if (!emailRef || !passwordRef || !rememberMeRef) { + setError("Please fill in all fields"); + return; + } + + const email = emailRef.value; + const password = passwordRef.value; + const rememberMe = rememberMeRef.checked; + + const response = await fetch("/api/trpc/auth.emailPasswordLogin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, rememberMe }) + }); + + const result = await response.json(); + + if (response.ok && result.result?.data?.success) { + setShowPasswordSuccess(true); + revalidateAuth(); // Refresh auth state globally + setTimeout(() => { + navigate("/account", { replace: true }); + }, 500); + return; + } + + const errorMessage = result.error?.message || ""; + const errorCode = result.error?.data?.code; + + if (isRateLimited(errorCode, errorMessage)) { + setError(errorMessage); + } else if ( + errorCode === "FORBIDDEN" || + errorMessage.includes("Account locked") || + errorMessage.includes("Account is locked") + ) { + setError(errorMessage); + } else { + setShowPasswordError(true); + } + }; + + const submitEmailLink = async () => { + if (!emailRef || !rememberMeRef) { + setError("Please enter your email"); + return; + } + + const email = emailRef.value; + const rememberMe = rememberMeRef.checked; + + if (!isValidEmail(email)) { + setError("Invalid email address"); + return; + } + + const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, rememberMe }) + }); + + const result = await response.json(); + + if (response.ok && result.result?.data?.success) { + setEmailSent(true); + + // Set countdown directly - cookie might not be readable immediately + const expirationTime = new Date( + Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS + ); + startCountdown(expirationTime); + return; + } + + const errorMsg = + result.error?.message || + result.result?.data?.message || + "Failed to send email"; + const errorCode = result.error?.data?.code; + + if ( + isRateLimited(errorCode, errorMsg) || + errorMsg.includes("countdown not expired") + ) { + setError( + errorMsg.includes("countdown") + ? "Please wait before requesting another email link" + : errorMsg + ); + + // Start the countdown timer when rate limited + const timer = getClientCookie("emailLoginLinkRequested"); + if (timer) { + try { + startCountdown(timer); + } catch (e) { + console.error("Failed to start countdown from cookie:", e); + } + } + } else { + setError(errorMsg); + } + }; + const formHandler = async (e: Event) => { e.preventDefault(); setLoading(true); @@ -158,181 +330,11 @@ export default function LoginPage() { try { if (register()) { - if (!emailRef || !passwordRef || !passwordConfRef) { - setError("Please fill in all fields"); - setLoading(false); - return; - } - - const email = emailRef.value; - const password = passwordRef.value; - const passwordConf = passwordConfRef.value; - - if (!isValidEmail(email)) { - setError("Invalid email address"); - setLoading(false); - return; - } - - const passwordValidation = validatePassword(password); - if (!passwordValidation.isValid) { - setError(passwordValidation.errors[0] || "Invalid password"); - setLoading(false); - return; - } - - if (password !== passwordConf) { - setError("passwordMismatch"); - setLoading(false); - return; - } - - const response = await fetch("/api/trpc/auth.emailRegistration", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email, - password, - passwordConfirmation: passwordConf - }) - }); - - const result = await response.json(); - - if (response.ok && result.result?.data) { - navigate("/account", { replace: true }); - } else { - const errorMsg = - result.error?.message || - result.result?.data?.message || - "Registration failed"; - const errorCode = result.error?.data?.code; - - if ( - errorCode === "TOO_MANY_REQUESTS" || - errorMsg.includes("Too many attempts") - ) { - setError(errorMsg); - } else if ( - errorMsg.includes("duplicate") || - errorMsg.includes("already exists") - ) { - if (errorMsg.includes("sign in and add a password")) { - setError("provider_exists"); - } else { - setError("duplicate"); - } - } else { - setError(errorMsg); - } - } + await submitRegister(); } else if (usePassword()) { - if (!emailRef || !passwordRef || !rememberMeRef) { - setError("Please fill in all fields"); - setLoading(false); - return; - } - - const email = emailRef.value; - const password = passwordRef.value; - const rememberMe = rememberMeRef.checked; - - const response = await fetch("/api/trpc/auth.emailPasswordLogin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, rememberMe }) - }); - - const result = await response.json(); - - if (response.ok && result.result?.data?.success) { - setShowPasswordSuccess(true); - revalidateAuth(); // Refresh auth state globally - setTimeout(() => { - navigate("/account", { replace: true }); - }, 500); - } else { - const errorMessage = result.error?.message || ""; - const errorCode = result.error?.data?.code; - - if ( - errorCode === "TOO_MANY_REQUESTS" || - errorMessage.includes("Too many attempts") - ) { - setError(errorMessage); - } else if ( - errorCode === "FORBIDDEN" || - errorMessage.includes("Account locked") || - errorMessage.includes("Account is locked") - ) { - setError(errorMessage); - } else { - setShowPasswordError(true); - } - } + await submitPasswordLogin(); } else { - if (!emailRef || !rememberMeRef) { - setError("Please enter your email"); - setLoading(false); - return; - } - - const email = emailRef.value; - const rememberMe = rememberMeRef.checked; - - if (!isValidEmail(email)) { - setError("Invalid email address"); - setLoading(false); - return; - } - - const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, rememberMe }) - }); - - const result = await response.json(); - - if (response.ok && result.result?.data?.success) { - setEmailSent(true); - - // Set countdown directly - cookie might not be readable immediately - const expirationTime = new Date( - Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS - ); - startCountdown(expirationTime); - } else { - const errorMsg = - result.error?.message || - result.result?.data?.message || - "Failed to send email"; - const errorCode = result.error?.data?.code; - - if ( - errorCode === "TOO_MANY_REQUESTS" || - errorMsg.includes("countdown not expired") || - errorMsg.includes("Too many attempts") - ) { - setError( - errorMsg.includes("countdown") - ? "Please wait before requesting another email link" - : errorMsg - ); - - // Start the countdown timer when rate limited - const timer = getClientCookie("emailLoginLinkRequested"); - if (timer) { - try { - startCountdown(timer); - } catch (e) { - console.error("Failed to start countdown from cookie:", e); - } - } - } else { - setError(errorMsg); - } - } + await submitEmailLink(); } } catch (err: any) { console.error("Login error:", err); diff --git a/src/routes/test.tsx b/src/routes/test.tsx index c889145..f85522e 100644 --- a/src/routes/test.tsx +++ b/src/routes/test.tsx @@ -863,7 +863,6 @@ export default function TestPage() { setErrors({ ...errors(), [key]: "" }); try { - // Get input - either from edited JSON or sample let input = endpoint.sampleInput; const editedInput = inputEdits()[key]; if (editedInput) { diff --git a/src/server/analytics.ts b/src/server/analytics.ts index 7af4130..f1d291e 100644 --- a/src/server/analytics.ts +++ b/src/server/analytics.ts @@ -117,14 +117,11 @@ function scheduleAnalyticsFlush(): void { */ export async function logVisit(entry: AnalyticsEntry): Promise { try { - // Add to buffer analyticsBuffer.entries.push(entry); - // Flush if batch size reached if (analyticsBuffer.entries.length >= CACHE_CONFIG.ANALYTICS_BATCH_SIZE) { await flushAnalyticsBuffer(); } else { - // Schedule periodic flush scheduleAnalyticsFlush(); } } catch (error) { @@ -422,7 +419,6 @@ export async function getPerformanceStats(days: number = 30): Promise<{ }> { const conn = ConnectionFactory(); - // Get average metrics const avgResult = await conn.execute({ sql: `SELECT AVG(lcp) as avgLcp, @@ -472,7 +468,6 @@ export async function getPerformanceStats(days: number = 30): Promise<{ args: [] }); - // Get performance by path (only for non-API paths) const byPathResult = await conn.execute({ sql: `SELECT path, diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index f218f36..95d513f 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -80,11 +80,9 @@ import { * In development: ctx.event might be H3Event directly */ function getH3Event(ctx: Context): H3Event { - // Check if nativeEvent exists (production) if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) { return ctx.event.nativeEvent as H3Event; } - // Otherwise, assume ctx.event is H3Event (development) return ctx.event as unknown as H3Event; } @@ -245,7 +243,6 @@ export const authRouter = createTRPCRouter({ try { await conn.execute({ sql: insertQuery, args: insertParams }); - // Also create UserProvider entry for new user await linkProvider(userId, "github", { providerUserId: login, email: email, @@ -300,7 +297,6 @@ export const authRouter = createTRPCRouter({ } catch (error) { console.error("[GitHub Callback] Error during OAuth flow:", error); - // Log failed OAuth login const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", @@ -452,7 +448,6 @@ export const authRouter = createTRPCRouter({ args: insertParams }); - // Also create UserProvider entry for new user await linkProvider(userId, "google", { providerUserId: email, email: email, @@ -481,7 +476,6 @@ export const authRouter = createTRPCRouter({ } } - // Issue JWT (OAuth defaults to remember me) const event = getH3Event(ctx); const clientIP = getClientIP(event); const userAgent = getUserAgent(event); @@ -631,7 +625,6 @@ export const authRouter = createTRPCRouter({ } catch (error) { console.error("[Email Login] Error during login:", error); - // Log failed email link login const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", @@ -685,10 +678,7 @@ export const authRouter = createTRPCRouter({ }); } - // Check if there's a valid JWT token with this code - // We need to find the token that was generated for this email - // Since we can't store tokens in DB efficiently, we'll verify against the cookie - // Get the token from cookie (we'll store it when sending email) + // Tokens aren't stored in DB; verify the code against the JWT cookie set when the email was sent const storedToken = getCookie(getH3Event(ctx), "emailLoginToken"); if (!storedToken) { throw new TRPCError({ @@ -697,7 +687,6 @@ export const authRouter = createTRPCRouter({ }); } - // Verify the JWT and check the code const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); let payload; try { @@ -756,7 +745,6 @@ export const authRouter = createTRPCRouter({ } catch (error) { console.error("[Email Code Login] Error during login:", error); - // Log failed code login const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.login.failed", @@ -808,7 +796,6 @@ export const authRouter = createTRPCRouter({ const conn = ConnectionFactory(); - // Get user ID for audit log const userRes = await conn.execute({ sql: "SELECT id FROM User WHERE email = ?", args: [email] @@ -819,7 +806,6 @@ export const authRouter = createTRPCRouter({ const params = [true, email]; await conn.execute({ sql: query, args: params }); - // Log successful email verification const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId, @@ -835,7 +821,6 @@ export const authRouter = createTRPCRouter({ message: "Email verification success, you may close this window" }; } catch (error) { - // Log failed email verification const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.email.verify.complete", @@ -864,7 +849,6 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { email, password, passwordConfirmation, rememberMe } = input; - // Apply rate limiting const clientIP = getClientIP(getH3Event(ctx)); await rateLimitRegistration(clientIP, getH3Event(ctx)); @@ -876,10 +860,8 @@ export const authRouter = createTRPCRouter({ }); } - // Check if email already exists (User table or UserProvider table) const existingUserId = await findUserByEmail(email); if (existingUserId) { - // User exists - check if they have a password const conn = ConnectionFactory(); const userCheck = await conn.execute({ sql: "SELECT password_hash, provider FROM User WHERE id = ?", @@ -916,13 +898,11 @@ export const authRouter = createTRPCRouter({ args: [userId, email, passwordHash, "email"] }); - // Create UserProvider entry for email auth await linkProvider(userId, "email", { providerUserId: email, email: email }); - // Issue auth token with client info const event = getH3Event(ctx); const clientIP = getClientIP(event); const userAgent = getUserAgent(event); @@ -930,13 +910,11 @@ export const authRouter = createTRPCRouter({ await issueAuthToken({ event, userId, - rememberMe: rememberMe ?? true + rememberMe, }); - // Set CSRF token setCSRFToken(event); - // Log successful registration await logAuditEvent({ userId, eventType: "auth.register.success", @@ -948,7 +926,6 @@ export const authRouter = createTRPCRouter({ return { success: true, message: "success" }; } catch (e) { - // Log failed registration const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.register.failed", @@ -976,7 +953,6 @@ export const authRouter = createTRPCRouter({ try { const { email, password, rememberMe } = input; - // Apply rate limiting const clientIP = getClientIP(getH3Event(ctx)); await rateLimitLogin(email, clientIP, getH3Event(ctx)); @@ -992,9 +968,7 @@ export const authRouter = createTRPCRouter({ const passwordHash = user?.password_hash || null; const passwordMatch = await checkPasswordSafe(password, passwordHash); - // Check all conditions after password verification if (!user || !passwordHash || !passwordMatch) { - // Record failed login attempt if user exists if (user?.id) { const lockoutStatus = await recordFailedLogin(user.id); @@ -1032,7 +1006,6 @@ export const authRouter = createTRPCRouter({ } } - // Log failed login attempt try { const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ @@ -1060,7 +1033,6 @@ export const authRouter = createTRPCRouter({ }); } - // Check if account is locked before allowing login const lockoutCheck = await checkAccountLockout(user.id); if (lockoutCheck.isLocked) { const remainingSec = Math.ceil( @@ -1083,22 +1055,18 @@ export const authRouter = createTRPCRouter({ }); } - // Reset failed attempts on successful login await resetFailedAttempts(user.id); - // Reset rate limits on successful login await resetLoginRateLimits(email, clientIP); - // Issue JWT for authenticated user const event = getH3Event(ctx); const userAgent = getUserAgent(event); await issueAuthToken({ event, userId: user.id, - rememberMe: rememberMe ?? false + rememberMe, }); - // Set CSRF token for authenticated user setCSRFToken(event); // Log successful login (wrap in try-catch to ensure it never blocks auth flow) @@ -1106,7 +1074,7 @@ export const authRouter = createTRPCRouter({ await logAuditEvent({ userId: user.id, eventType: "auth.login.success", - eventData: { method: "password", rememberMe: rememberMe ?? false }, + eventData: { method: "password", rememberMe }, ipAddress: clientIP, userAgent, success: true @@ -1251,7 +1219,6 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { email } = input; - // Apply rate limiting const clientIP = getClientIP(getH3Event(ctx)); await rateLimitPasswordReset(clientIP, getH3Event(ctx)); @@ -1303,7 +1270,6 @@ export const authRouter = createTRPCRouter({ } ); - // Log password reset request const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: user.id, @@ -1316,7 +1282,6 @@ export const authRouter = createTRPCRouter({ return { success: true, message: "email sent" }; } catch (error) { - // Log failed password reset request (only if not rate limited) if ( !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") ) { @@ -1371,7 +1336,6 @@ export const authRouter = createTRPCRouter({ } try { - // Validate and consume the password reset token const tokenValidation = await validatePasswordResetToken(token); if (!tokenValidation) { @@ -1415,10 +1379,8 @@ export const authRouter = createTRPCRouter({ }); } - // Mark token as used await markPasswordResetTokenUsed(tokenId); - // Log successful password reset const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: userId, @@ -1431,7 +1393,6 @@ export const authRouter = createTRPCRouter({ return { success: true, message: "success" }; } catch (error) { - // Log failed password reset const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ eventType: "auth.password.reset.complete", @@ -1459,7 +1420,6 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { email } = input; - // Apply rate limiting const clientIP = getClientIP(getH3Event(ctx)); await rateLimitEmailVerification(clientIP, getH3Event(ctx)); @@ -1520,7 +1480,6 @@ export const authRouter = createTRPCRouter({ } ); - // Log email verification request const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ userId: user.id, @@ -1533,7 +1492,6 @@ export const authRouter = createTRPCRouter({ return { success: true, message: "Verification email sent" }; } catch (error) { - // Log failed email verification request (only if not rate limited) if ( !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") ) { diff --git a/src/server/api/routers/database.ts b/src/server/api/routers/database.ts index a58a89e..4b853a0 100644 --- a/src/server/api/routers/database.ts +++ b/src/server/api/routers/database.ts @@ -462,58 +462,50 @@ export const databaseRouter = createTRPCRouter({ } } - let query = "UPDATE Post SET "; + let sets: string[] = []; let params: any[] = []; - let first = true; if (input.title !== undefined && input.title !== null) { - query += first ? "title = ?" : ", title = ?"; + sets.push("title = ?"); params.push(input.title); - first = false; } if (input.subtitle !== undefined && input.subtitle !== null) { - query += first ? "subtitle = ?" : ", subtitle = ?"; + sets.push("subtitle = ?"); params.push(input.subtitle); - first = false; } if (input.body !== undefined && input.body !== null) { - query += first ? "body = ?" : ", body = ?"; + sets.push("body = ?"); params.push(input.body); - first = false; } if (input.banner_photo !== undefined && input.banner_photo !== null) { - query += first ? "banner_photo = ?" : ", banner_photo = ?"; + sets.push("banner_photo = ?"); if (input.banner_photo === "_DELETE_IMAGE_") { params.push(null); } else { params.push(env.VITE_AWS_BUCKET_STRING + input.banner_photo); } - first = false; } if (input.published !== undefined && input.published !== null) { - query += first ? "published = ?" : ", published = ?"; + sets.push("published = ?"); params.push(input.published); - first = false; } if (shouldSetPublishDate) { - query += first ? "date = ?" : ", date = ?"; + sets.push("date = ?"); params.push(new Date().toISOString()); - first = false; } - query += first ? "last_edited_date = ?" : ", last_edited_date = ?"; + sets.push("last_edited_date = ?"); params.push(new Date().toISOString()); - first = false; - query += first ? "author_id = ?" : ", author_id = ?"; + sets.push("author_id = ?"); params.push(input.author_id); - query += " WHERE id = ?"; + let query = "UPDATE Post SET " + sets.join(", ") + " WHERE id = ?"; params.push(input.id); const results = await conn.execute({ sql: query, args: params }); diff --git a/src/server/api/routers/downloads.ts b/src/server/api/routers/downloads.ts index 9137ea4..20af36c 100644 --- a/src/server/api/routers/downloads.ts +++ b/src/server/api/routers/downloads.ts @@ -36,7 +36,6 @@ async function getLatestDMG( throw new Error(`No DMG files found in S3 with prefix ${prefix}`); } - // Filter for .dmg files only and sort by LastModified (newest first) const dmgFiles = response.Contents.filter((obj) => obj.Key?.endsWith(".dmg") ).sort((a, b) => { @@ -103,7 +102,6 @@ export const downloadsRouter = createTRPCRouter({ } else if (input.asset_name === "inputhalo") { fileKey = await getLatestInputHaloDMG(client, bucket); } else { - // Use static mapping for other assets fileKey = assets[input.asset_name]; if (!fileKey) { diff --git a/src/server/api/routers/git-activity.ts b/src/server/api/routers/git-activity.ts index b982d32..e49de8e 100644 --- a/src/server/api/routers/git-activity.ts +++ b/src/server/api/routers/git-activity.ts @@ -33,7 +33,6 @@ export const gitActivityRouter = createTRPCRouter({ `github-commits-${input.limit}`, CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS, async () => { - // Use Events API to get recent push events const eventsResponse = await fetchWithTimeout( `https://api.github.com/users/MikeFreno/events/public?per_page=100`, { @@ -48,7 +47,6 @@ export const gitActivityRouter = createTRPCRouter({ await checkResponse(eventsResponse); const events = await eventsResponse.json(); - // Collect (repo, sha) pairs from push events up front const toFetch: { repoName: string; sha: string }[] = []; for (const event of events) { if (event.type !== "PushEvent") continue; diff --git a/src/server/api/routers/misc.ts b/src/server/api/routers/misc.ts index 19acdb4..344fa92 100644 --- a/src/server/api/routers/misc.ts +++ b/src/server/api/routers/misc.ts @@ -72,13 +72,7 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void { // Account-deletion request email — product-aware // ============================================================ // -// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit- -// tested in `bun:test` without a populated `.env`. Re-exported here for the -// tRPC mutation below + for callers that already import from `misc`. -// Import into local scope FIRST — `sendDeletionRequestEmail` below uses -// these names directly. A bare `export { ... } from` re-export does NOT make -// the bindings available locally, which caused a ReferenceError that crashed -// the entire tRPC router (503 on every /api/trpc call). +// Bare "export … from" doesn't bind names locally — import first or the router throws ReferenceError (503s every /api/trpc call) import { DELETION_PRODUCT_SCHEMA, deletionCookieName, @@ -256,7 +250,6 @@ export const miscRouter = createTRPCRouter({ lastModified: item.LastModified?.toISOString() || "" })) || []; - // Filter out thumbnail files (ending with -small.ext) const mainFiles = files.filter( (file) => !file.key.match(/-small\.(jpg|jpeg|png|gif)$/i) ); @@ -376,7 +369,6 @@ export const miscRouter = createTRPCRouter({ }) ) .mutation(async ({ input }) => { - // Verify Cloudflare Turnstile token const turnstileValid = await verifyTurnstileToken( input.turnstileToken, env.TURNSTILE_SECRET_KEY, diff --git a/src/server/api/routers/nessa-ownership.test.ts b/src/server/api/routers/nessa-ownership.test.ts index 4718b3c..06cedef 100644 --- a/src/server/api/routers/nessa-ownership.test.ts +++ b/src/server/api/routers/nessa-ownership.test.ts @@ -10,6 +10,7 @@ */ import { describe, it, expect, mock, beforeEach } from "bun:test"; +import { TRPCError } from "@trpc/server"; import type { Client } from "@libsql/client/web"; // Prevent the env/server.ts client-side guard from throwing during tests @@ -288,7 +289,6 @@ describe("static audit: every targeted mutation handler uses ctx", () => { const source = await Bun.file(import.meta.dir + "/nessa.ts").text(); for (const name of MUTATIONS) { - // Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx const re = new RegExp( `${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`, "s" @@ -305,7 +305,6 @@ describe("static audit: every targeted mutation handler uses ctx", () => { const source = await Bun.file(import.meta.dir + "/nessa.ts").text(); for (const name of MUTATIONS) { - // Find the block for this mutation and check it references ctx const re = new RegExp( `${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`, "s" @@ -318,12 +317,55 @@ describe("static audit: every targeted mutation handler uses ctx", () => { } }); - it("bulkUpsert filters exerciseLibrary by userId", async () => { - const source = await Bun.file(import.meta.dir + "/nessa.ts").text(); - const bulkSection = source.match( - /if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/ - ); - expect(bulkSection).toBeTruthy(); - expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId"); + it("upsertExerciseLibrary rejects an exercise owned by another user", async () => { + const mod = await import("./nessa"); + const conn = makeMockConn([]); + const exercise = { + id: EXERCISE_ID, + userId: USER_B, + name: "Squat", + category: "Strength" + }; + let caught: unknown; + try { + await mod.upsertExerciseLibrary(conn, USER_A, [exercise]); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(TRPCError); + expect((caught as TRPCError).code).toBe("FORBIDDEN"); + expect((caught as TRPCError).message).toBe("User mismatch"); + }); + + it("upsertExerciseLibrary upserts an exercise owned by the caller", async () => { + const mod = await import("./nessa"); + const conn = makeMockConn([{ userId: USER_A }]); + const exercise = { + id: EXERCISE_ID, + userId: USER_A, + name: "Squat", + category: "Strength" + }; + await expect( + mod.upsertExerciseLibrary(conn, USER_A, [exercise]) + ).resolves.toBeUndefined(); + expect(conn.execute).toHaveBeenCalledWith({ + sql: expect.stringContaining( + "INSERT INTO exerciseLibrary (id, userId, name, category" + ), + args: [ + EXERCISE_ID, + USER_A, + "Squat", + "Strength", + null, + null, + null, + null, + null, + null, + null + ] + }); }); }); diff --git a/src/server/api/routers/nessa.ts b/src/server/api/routers/nessa.ts index 7acf7d9..bf86b66 100644 --- a/src/server/api/routers/nessa.ts +++ b/src/server/api/routers/nessa.ts @@ -7,25 +7,45 @@ import type { Client } from "@libsql/client/web"; const NESSA_CACHE_TTL_MS = 5 * 60 * 1000; +/** + * Assert that the record identified by id in the given table belongs to userId. + * Shared by assertWorkoutOwned, assertAuthProviderOwned, and + * assertExerciseLibraryOwned. + */ +async function assertOwnedBy( + conn: Client, + table: string, + id: string, + userId: string, + notFoundMessage: string, + forbiddenMessage: string +) { + const row = await conn.execute({ + sql: `SELECT userId FROM ${table} WHERE id = ?`, + args: [id] + }); + if (row.rows.length === 0) { + throw new TRPCError({ code: "NOT_FOUND", message: notFoundMessage }); + } + if (row.rows[0].userId !== userId) { + throw new TRPCError({ code: "FORBIDDEN", message: forbiddenMessage }); + } +} + /** Assert that the workout identified by workoutId is owned by userId */ export async function assertWorkoutOwned( conn: Client, workoutId: string, userId: string ) { - const row = await conn.execute({ - sql: "SELECT userId FROM workouts WHERE id = ?", - args: [workoutId] - }); - if (row.rows.length === 0) { - throw new TRPCError({ code: "NOT_FOUND", message: "Workout not found" }); - } - if ((row.rows[0] as any).userId !== userId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Not the workout owner" - }); - } + await assertOwnedBy( + conn, + "workouts", + workoutId, + userId, + "Workout not found", + "Not the workout owner" + ); } /** Assert that the auth provider record identified by providerId is owned by userId */ @@ -34,22 +54,14 @@ export async function assertAuthProviderOwned( providerId: string, userId: string ) { - const row = await conn.execute({ - sql: "SELECT userId FROM authProviders WHERE id = ?", - args: [providerId] - }); - if (row.rows.length === 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Auth provider not found" - }); - } - if ((row.rows[0] as any).userId !== userId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Not the auth provider owner" - }); - } + await assertOwnedBy( + conn, + "authProviders", + providerId, + userId, + "Auth provider not found", + "Not the auth provider owner" + ); } /** Assert that the exercise library record identified by exerciseId is owned by userId */ @@ -58,19 +70,14 @@ export async function assertExerciseLibraryOwned( exerciseId: string, userId: string ) { - const row = await conn.execute({ - sql: "SELECT userId FROM exerciseLibrary WHERE id = ?", - args: [exerciseId] - }); - if (row.rows.length === 0) { - throw new TRPCError({ code: "NOT_FOUND", message: "Exercise not found" }); - } - if ((row.rows[0] as any).userId !== userId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Not the exercise owner" - }); - } + await assertOwnedBy( + conn, + "exerciseLibrary", + exerciseId, + userId, + "Exercise not found", + "Not the exercise owner" + ); } const paginatedQuerySchema = z.object({ @@ -239,6 +246,401 @@ const bulkSchema = z.object({ authProviders: z.array(providerSchema).optional() }); +async function upsertUsers( + conn: Client, + userId: string, + users: z.infer[] +) { + for (const user of users) { + if (user.id !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET email = excluded.email, emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, provider = excluded.provider, appleUserId = excluded.appleUserId, status = excluded.status, updatedAt = datetime('now')`, + args: [ + user.id, + user.email ?? null, + user.emailVerified ?? 0, + user.firstName ?? null, + user.lastName ?? null, + user.displayName ?? null, + user.avatarUrl ?? null, + user.provider ?? null, + user.appleUserId ?? null, + user.status ?? "active" + ] + }); + } +} + +export async function upsertExerciseLibrary( + conn: Client, + userId: string, + exerciseLibrary: z.infer[] +) { + for (const exercise of exerciseLibrary) { + if (exercise.userId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`, + args: [ + exercise.id, + exercise.userId, + exercise.name, + exercise.category, + exercise.muscleGroups ?? null, + exercise.equipment ?? null, + exercise.instructions ?? null, + exercise.defaultSets ?? null, + exercise.defaultReps ?? null, + exercise.defaultRestSeconds ?? null, + exercise.notes ?? null + ] + }); + } +} + +async function upsertWorkoutPlans( + conn: Client, + userId: string, + workoutPlans: z.infer[] +) { + for (const plan of workoutPlans) { + if (plan.userId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO workoutPlans (id, userId, name, description, category, difficulty, durationMinutes, type, isPublic) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, category = excluded.category, difficulty = excluded.difficulty, durationMinutes = excluded.durationMinutes, type = excluded.type, isPublic = excluded.isPublic, updatedAt = datetime('now')`, + args: [ + plan.id, + plan.userId, + plan.name, + plan.description ?? null, + plan.category, + plan.difficulty ?? "intermediate", + plan.durationMinutes ?? null, + plan.type, + plan.isPublic ?? 0 + ] + }); + } +} + +async function upsertPlanExercises( + conn: Client, + userId: string, + planExercises: z.infer[] +) { + for (const planExercise of planExercises) { + const planCheck = await conn.execute({ + sql: "SELECT userId FROM workoutPlans WHERE id = ?", + args: [planExercise.planId] + }); + if ( + !planCheck.rows.length || + planCheck.rows[0].userId !== userId + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO planExercises (id, planId, exerciseId, name, category, orderIndex, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET exerciseId = excluded.exerciseId, name = excluded.name, category = excluded.category, orderIndex = excluded.orderIndex, notes = excluded.notes`, + args: [ + planExercise.id, + planExercise.planId, + planExercise.exerciseId ?? null, + planExercise.name, + planExercise.category, + planExercise.orderIndex, + planExercise.notes ?? null + ] + }); + } +} + +async function upsertPlanSets( + conn: Client, + userId: string, + planSets: z.infer[] +) { + for (const planSet of planSets) { + const planExerciseCheck = await conn.execute({ + sql: "SELECT planId FROM planExercises WHERE id = ?", + args: [planSet.planExerciseId] + }); + if (planExerciseCheck.rows.length) { + const planCheck = await conn.execute({ + sql: "SELECT userId FROM workoutPlans WHERE id = ?", + args: [planExerciseCheck.rows[0].planId] + }); + if ( + !planCheck.rows.length || + planCheck.rows[0].userId !== userId + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + } + await conn.execute({ + sql: `INSERT INTO planSets (id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET setNumber = excluded.setNumber, reps = excluded.reps, weight = excluded.weight, durationSeconds = excluded.durationSeconds, rpe = excluded.rpe, restAfterSeconds = excluded.restAfterSeconds, isWarmup = excluded.isWarmup, isDropset = excluded.isDropset, notes = excluded.notes`, + args: [ + planSet.id, + planSet.planExerciseId, + planSet.setNumber, + planSet.reps ?? null, + planSet.weight ?? null, + planSet.durationSeconds ?? null, + planSet.rpe ?? null, + planSet.restAfterSeconds ?? null, + planSet.isWarmup ?? 0, + planSet.isDropset ?? 0, + planSet.notes ?? null + ] + }); + } +} + +async function upsertRoutePoints( + conn: Client, + userId: string, + routePoints: z.infer[] +) { + for (const point of routePoints) { + const planCheck = await conn.execute({ + sql: "SELECT userId FROM workoutPlans WHERE id = ?", + args: [point.planId] + }); + if ( + !planCheck.rows.length || + planCheck.rows[0].userId !== userId + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO routePoints (id, planId, latitude, longitude, orderIndex, isWaypoint) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET latitude = excluded.latitude, longitude = excluded.longitude, orderIndex = excluded.orderIndex, isWaypoint = excluded.isWaypoint`, + args: [ + point.id, + point.planId, + point.latitude, + point.longitude, + point.orderIndex, + point.isWaypoint ?? 0 + ] + }); + } +} + +async function upsertWorkouts( + conn: Client, + userId: string, + workouts: z.infer[] +) { + for (const workout of workouts) { + if (workout.userId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO workouts (id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET planId = excluded.planId, type = excluded.type, name = excluded.name, startDate = excluded.startDate, endDate = excluded.endDate, durationSeconds = excluded.durationSeconds, distanceMeters = excluded.distanceMeters, calories = excluded.calories, averageHeartRate = excluded.averageHeartRate, maxHeartRate = excluded.maxHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, status = excluded.status, source = excluded.source, healthKitUUID = excluded.healthKitUUID, notes = excluded.notes, updatedAt = datetime('now')`, + args: [ + workout.id, + workout.userId, + workout.planId ?? null, + workout.type, + workout.name ?? null, + workout.startDate, + workout.endDate ?? null, + workout.durationSeconds ?? null, + workout.distanceMeters ?? null, + workout.calories ?? null, + workout.averageHeartRate ?? null, + workout.maxHeartRate ?? null, + workout.averagePace ?? null, + workout.elevationGain ?? null, + workout.status, + workout.source, + workout.healthKitUUID ?? null, + workout.notes ?? null + ] + }); + } +} + +async function upsertHeartRateSamples( + conn: Client, + userId: string, + heartRateSamples: z.infer[] +) { + for (const sample of heartRateSamples) { + const workoutCheck = await conn.execute({ + sql: "SELECT userId FROM workouts WHERE id = ?", + args: [sample.workoutId] + }); + if ( + !workoutCheck.rows.length || + workoutCheck.rows[0].userId !== userId + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, bpm = excluded.bpm, source = excluded.source`, + args: [ + sample.id, + sample.workoutId, + sample.timestamp, + sample.bpm, + sample.source ?? null + ] + }); + } +} + +async function upsertLocationSamples( + conn: Client, + userId: string, + locationSamples: z.infer[] +) { + for (const sample of locationSamples) { + const workoutCheck = await conn.execute({ + sql: "SELECT userId FROM workouts WHERE id = ?", + args: [sample.workoutId] + }); + if ( + !workoutCheck.rows.length || + workoutCheck.rows[0].userId !== userId + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, latitude = excluded.latitude, longitude = excluded.longitude, altitude = excluded.altitude, horizontalAccuracy = excluded.horizontalAccuracy, verticalAccuracy = excluded.verticalAccuracy, speed = excluded.speed, course = excluded.course`, + args: [ + sample.id, + sample.workoutId, + sample.timestamp, + sample.latitude, + sample.longitude, + sample.altitude ?? null, + sample.horizontalAccuracy ?? null, + sample.verticalAccuracy ?? null, + sample.speed ?? null, + sample.course ?? null + ] + }); + } +} + +async function upsertWorkoutSplits( + conn: Client, + userId: string, + workoutSplits: z.infer[] +) { + for (const split of workoutSplits) { + const workoutCheck = await conn.execute({ + sql: "SELECT userId FROM workouts WHERE id = ?", + args: [split.workoutId] + }); + if ( + !workoutCheck.rows.length || + workoutCheck.rows[0].userId !== userId + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET splitNumber = excluded.splitNumber, distanceMeters = excluded.distanceMeters, durationSeconds = excluded.durationSeconds, startTimestamp = excluded.startTimestamp, endTimestamp = excluded.endTimestamp, averageHeartRate = excluded.averageHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, elevationLoss = excluded.elevationLoss`, + args: [ + split.id, + split.workoutId, + split.splitNumber, + split.distanceMeters, + split.durationSeconds, + split.startTimestamp, + split.endTimestamp, + split.averageHeartRate ?? null, + split.averagePace ?? null, + split.elevationGain ?? null, + split.elevationLoss ?? null + ] + }); + } +} + +async function upsertAuthProviders( + conn: Client, + userId: string, + authProviders: z.infer[] +) { + for (const provider of authProviders) { + if (provider.userId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User mismatch" + }); + } + await conn.execute({ + sql: `INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, providerUserId = excluded.providerUserId, email = excluded.email, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, lastUsedAt = datetime('now')`, + args: [ + provider.id, + provider.userId, + provider.provider, + provider.providerUserId ?? null, + provider.email ?? null, + provider.displayName ?? null, + provider.avatarUrl ?? null + ] + }); + } +} + export const nessaDbRouter = createTRPCRouter({ health: nessaProcedure.query(async () => { try { @@ -1826,356 +2228,41 @@ export const nessaDbRouter = createTRPCRouter({ try { const conn = NessaConnectionFactory(); - if (input.users?.length) { - for (const user of input.users) { - if (user.id !== ctx.nessaUserId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET email = excluded.email, emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, provider = excluded.provider, appleUserId = excluded.appleUserId, status = excluded.status, updatedAt = datetime('now')`, - args: [ - user.id, - user.email ?? null, - user.emailVerified ?? 0, - user.firstName ?? null, - user.lastName ?? null, - user.displayName ?? null, - user.avatarUrl ?? null, - user.provider ?? null, - user.appleUserId ?? null, - user.status ?? "active" - ] - }); - } - } - - if (input.exerciseLibrary?.length) { - for (const exercise of input.exerciseLibrary) { - if (exercise.userId !== ctx.nessaUserId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`, - args: [ - exercise.id, - exercise.userId, - exercise.name, - exercise.category, - exercise.muscleGroups ?? null, - exercise.equipment ?? null, - exercise.instructions ?? null, - exercise.defaultSets ?? null, - exercise.defaultReps ?? null, - exercise.defaultRestSeconds ?? null, - exercise.notes ?? null - ] - }); - } - } - - if (input.workoutPlans?.length) { - for (const plan of input.workoutPlans) { - if (plan.userId !== ctx.nessaUserId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO workoutPlans (id, userId, name, description, category, difficulty, durationMinutes, type, isPublic) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, category = excluded.category, difficulty = excluded.difficulty, durationMinutes = excluded.durationMinutes, type = excluded.type, isPublic = excluded.isPublic, updatedAt = datetime('now')`, - args: [ - plan.id, - plan.userId, - plan.name, - plan.description ?? null, - plan.category, - plan.difficulty ?? "intermediate", - plan.durationMinutes ?? null, - plan.type, - plan.isPublic ?? 0 - ] - }); - } - } - - if (input.planExercises?.length) { - for (const planExercise of input.planExercises) { - const planCheck = await conn.execute({ - sql: "SELECT userId FROM workoutPlans WHERE id = ?", - args: [planExercise.planId] - }); - if ( - !planCheck.rows.length || - planCheck.rows[0].userId !== ctx.nessaUserId - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO planExercises (id, planId, exerciseId, name, category, orderIndex, notes) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET exerciseId = excluded.exerciseId, name = excluded.name, category = excluded.category, orderIndex = excluded.orderIndex, notes = excluded.notes`, - args: [ - planExercise.id, - planExercise.planId, - planExercise.exerciseId ?? null, - planExercise.name, - planExercise.category, - planExercise.orderIndex, - planExercise.notes ?? null - ] - }); - } - } - - if (input.planSets?.length) { - for (const planSet of input.planSets) { - const planExerciseCheck = await conn.execute({ - sql: "SELECT planId FROM planExercises WHERE id = ?", - args: [planSet.planExerciseId] - }); - if (planExerciseCheck.rows.length) { - const planCheck = await conn.execute({ - sql: "SELECT userId FROM workoutPlans WHERE id = ?", - args: [planExerciseCheck.rows[0].planId] - }); - if ( - !planCheck.rows.length || - planCheck.rows[0].userId !== ctx.nessaUserId - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - } - await conn.execute({ - sql: `INSERT INTO planSets (id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET setNumber = excluded.setNumber, reps = excluded.reps, weight = excluded.weight, durationSeconds = excluded.durationSeconds, rpe = excluded.rpe, restAfterSeconds = excluded.restAfterSeconds, isWarmup = excluded.isWarmup, isDropset = excluded.isDropset, notes = excluded.notes`, - args: [ - planSet.id, - planSet.planExerciseId, - planSet.setNumber, - planSet.reps ?? null, - planSet.weight ?? null, - planSet.durationSeconds ?? null, - planSet.rpe ?? null, - planSet.restAfterSeconds ?? null, - planSet.isWarmup ?? 0, - planSet.isDropset ?? 0, - planSet.notes ?? null - ] - }); - } - } - - if (input.routePoints?.length) { - for (const point of input.routePoints) { - const planCheck = await conn.execute({ - sql: "SELECT userId FROM workoutPlans WHERE id = ?", - args: [point.planId] - }); - if ( - !planCheck.rows.length || - planCheck.rows[0].userId !== ctx.nessaUserId - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO routePoints (id, planId, latitude, longitude, orderIndex, isWaypoint) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET latitude = excluded.latitude, longitude = excluded.longitude, orderIndex = excluded.orderIndex, isWaypoint = excluded.isWaypoint`, - args: [ - point.id, - point.planId, - point.latitude, - point.longitude, - point.orderIndex, - point.isWaypoint ?? 0 - ] - }); - } - } - - if (input.workouts?.length) { - for (const workout of input.workouts) { - if (workout.userId !== ctx.nessaUserId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO workouts (id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET planId = excluded.planId, type = excluded.type, name = excluded.name, startDate = excluded.startDate, endDate = excluded.endDate, durationSeconds = excluded.durationSeconds, distanceMeters = excluded.distanceMeters, calories = excluded.calories, averageHeartRate = excluded.averageHeartRate, maxHeartRate = excluded.maxHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, status = excluded.status, source = excluded.source, healthKitUUID = excluded.healthKitUUID, notes = excluded.notes, updatedAt = datetime('now')`, - args: [ - workout.id, - workout.userId, - workout.planId ?? null, - workout.type, - workout.name ?? null, - workout.startDate, - workout.endDate ?? null, - workout.durationSeconds ?? null, - workout.distanceMeters ?? null, - workout.calories ?? null, - workout.averageHeartRate ?? null, - workout.maxHeartRate ?? null, - workout.averagePace ?? null, - workout.elevationGain ?? null, - workout.status, - workout.source, - workout.healthKitUUID ?? null, - workout.notes ?? null - ] - }); - } - } - - if (input.heartRateSamples?.length) { - for (const sample of input.heartRateSamples) { - const workoutCheck = await conn.execute({ - sql: "SELECT userId FROM workouts WHERE id = ?", - args: [sample.workoutId] - }); - if ( - !workoutCheck.rows.length || - workoutCheck.rows[0].userId !== ctx.nessaUserId - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, bpm = excluded.bpm, source = excluded.source`, - args: [ - sample.id, - sample.workoutId, - sample.timestamp, - sample.bpm, - sample.source ?? null - ] - }); - } - } - - if (input.locationSamples?.length) { - for (const sample of input.locationSamples) { - const workoutCheck = await conn.execute({ - sql: "SELECT userId FROM workouts WHERE id = ?", - args: [sample.workoutId] - }); - if ( - !workoutCheck.rows.length || - workoutCheck.rows[0].userId !== ctx.nessaUserId - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, latitude = excluded.latitude, longitude = excluded.longitude, altitude = excluded.altitude, horizontalAccuracy = excluded.horizontalAccuracy, verticalAccuracy = excluded.verticalAccuracy, speed = excluded.speed, course = excluded.course`, - args: [ - sample.id, - sample.workoutId, - sample.timestamp, - sample.latitude, - sample.longitude, - sample.altitude ?? null, - sample.horizontalAccuracy ?? null, - sample.verticalAccuracy ?? null, - sample.speed ?? null, - sample.course ?? null - ] - }); - } - } - - if (input.workoutSplits?.length) { - for (const split of input.workoutSplits) { - const workoutCheck = await conn.execute({ - sql: "SELECT userId FROM workouts WHERE id = ?", - args: [split.workoutId] - }); - if ( - !workoutCheck.rows.length || - workoutCheck.rows[0].userId !== ctx.nessaUserId - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET splitNumber = excluded.splitNumber, distanceMeters = excluded.distanceMeters, durationSeconds = excluded.durationSeconds, startTimestamp = excluded.startTimestamp, endTimestamp = excluded.endTimestamp, averageHeartRate = excluded.averageHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, elevationLoss = excluded.elevationLoss`, - args: [ - split.id, - split.workoutId, - split.splitNumber, - split.distanceMeters, - split.durationSeconds, - split.startTimestamp, - split.endTimestamp, - split.averageHeartRate ?? null, - split.averagePace ?? null, - split.elevationGain ?? null, - split.elevationLoss ?? null - ] - }); - } - } - - if (input.authProviders?.length) { - for (const provider of input.authProviders) { - if (provider.userId !== ctx.nessaUserId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User mismatch" - }); - } - await conn.execute({ - sql: `INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, providerUserId = excluded.providerUserId, email = excluded.email, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, lastUsedAt = datetime('now')`, - args: [ - provider.id, - provider.userId, - provider.provider, - provider.providerUserId ?? null, - provider.email ?? null, - provider.displayName ?? null, - provider.avatarUrl ?? null - ] - }); - } - } + await upsertUsers(conn, ctx.nessaUserId, input.users ?? []); + await upsertExerciseLibrary( + conn, + ctx.nessaUserId, + input.exerciseLibrary ?? [] + ); + await upsertWorkoutPlans(conn, ctx.nessaUserId, input.workoutPlans ?? []); + await upsertPlanExercises( + conn, + ctx.nessaUserId, + input.planExercises ?? [] + ); + await upsertPlanSets(conn, ctx.nessaUserId, input.planSets ?? []); + await upsertRoutePoints(conn, ctx.nessaUserId, input.routePoints ?? []); + await upsertWorkouts(conn, ctx.nessaUserId, input.workouts ?? []); + await upsertHeartRateSamples( + conn, + ctx.nessaUserId, + input.heartRateSamples ?? [] + ); + await upsertLocationSamples( + conn, + ctx.nessaUserId, + input.locationSamples ?? [] + ); + await upsertWorkoutSplits( + conn, + ctx.nessaUserId, + input.workoutSplits ?? [] + ); + await upsertAuthProviders( + conn, + ctx.nessaUserId, + input.authProviders ?? [] + ); return { success: true }; } catch (error) { diff --git a/src/server/api/routers/post-history.ts b/src/server/api/routers/post-history.ts index c22d4ef..96444d0 100644 --- a/src/server/api/routers/post-history.ts +++ b/src/server/api/routers/post-history.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import diff from "fast-diff"; -export function createDiffPatch( +function createDiffPatch( oldContent: string, newContent: string ): string { @@ -12,7 +12,7 @@ export function createDiffPatch( return JSON.stringify(changes); } -export function applyDiffPatch(baseContent: string, patchJson: string): string { +function applyDiffPatch(baseContent: string, patchJson: string): string { const changes = JSON.parse(patchJson); let result = ""; let position = 0; @@ -96,7 +96,6 @@ export const postHistoryRouter = createTRPCRouter({ const conn = ConnectionFactory(); - // Verify post exists and user is author const postCheck = await conn.execute({ sql: "SELECT author_id FROM Post WHERE id = ?", args: [input.postId] diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index ce9e220..3a7bd1c 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -254,7 +254,6 @@ export const userRouter = createTRPCRouter({ args: [passwordHash, userId] }); - // Send email notification about password being set if (user.email) { try { const h3Event = ctx.event.nativeEvent diff --git a/src/server/api/schemas/blog.ts b/src/server/api/schemas/blog.ts index b01a8d2..28d0b9e 100644 --- a/src/server/api/schemas/blog.ts +++ b/src/server/api/schemas/blog.ts @@ -6,105 +6,6 @@ import { z } from "zod"; * Schemas for post creation, updating, querying, and interactions */ -// ============================================================================ -// Post Category and Status -// ============================================================================ - -/** - * Post category enum (deprecated but kept for backward compatibility) - */ -export const postCategorySchema = z.enum(["blog", "project"]); - -// ============================================================================ -// Post Creation and Updates -// ============================================================================ - -/** - * Create new post schema - */ -export const createPostSchema = z.object({ - title: z - .string() - .min(1, "Title is required") - .max(200, "Title must be under 200 characters"), - subtitle: z - .string() - .max(300, "Subtitle must be under 300 characters") - .optional(), - body: z.string().min(1, "Post body is required"), - banner_photo: z.string().url("Must be a valid URL").optional(), - published: z.boolean().default(false), - category: postCategorySchema.default("blog"), - attachments: z.string().optional() -}); - -/** - * Update post schema (partial updates) - */ -export const updatePostSchema = z.object({ - postId: z.number(), - title: z.string().min(1).max(200).optional(), - subtitle: z.string().max(300).optional(), - body: z.string().min(1).optional(), - banner_photo: z.string().url().optional(), - published: z.boolean().optional(), - attachments: z.string().optional() -}); - -/** - * Delete post schema - */ -export const deletePostSchema = z.object({ - postId: z.number() -}); - -// ============================================================================ -// Post Queries and Filtering -// ============================================================================ - -/** - * Post sort mode enum - * Defines available sorting options for blog posts - */ -export const postSortModeSchema = z.enum([ - "newest", - "oldest", - "most_liked", - "most_read", - "most_comments" -]); - -/** - * Post query input schema - * Accepts optional filters (pipe-separated tags) and sort mode - */ -export const postQueryInputSchema = z.object({ - /** - * Pipe-separated list of tags to filter by - * e.g., "tech|design|javascript" - * Empty string or undefined means no filter - */ - filters: z.string().optional(), - - /** - * Sort mode for posts - * Defaults to "newest" if not specified - */ - sortBy: postSortModeSchema.default("newest") -}); - -/** - * Get single post by ID or slug - */ -export const getPostSchema = z - .object({ - postId: z.number().optional(), - slug: z.string().optional() - }) - .refine((data) => data.postId || data.slug, { - message: "Either postId or slug must be provided" - }); - // ============================================================================ // Post Interactions // ============================================================================ @@ -116,55 +17,8 @@ export const incrementPostReadSchema = z.object({ postId: z.number() }); -/** - * Like/unlike post - */ -export const togglePostLikeSchema = z.object({ - postId: z.number() -}); - -// ============================================================================ -// Tag Management -// ============================================================================ - -/** - * Add tags to post - */ -export const addTagsToPostSchema = z.object({ - postId: z.number(), - tags: z - .array(z.string().min(1).max(50)) - .min(1, "At least one tag is required") -}); - -/** - * Remove tag from post - */ -export const removeTagFromPostSchema = z.object({ - tagId: z.number() -}); - -/** - * Update post tags (replaces all tags) - */ -export const updatePostTagsSchema = z.object({ - postId: z.number(), - tags: z.array(z.string().min(1).max(50)) -}); - // ============================================================================ // Type Exports // ============================================================================ -export type PostCategory = z.infer; -export type CreatePostInput = z.infer; -export type UpdatePostInput = z.infer; -export type DeletePostInput = z.infer; -export type PostSortMode = z.infer; -export type PostQueryInput = z.infer; -export type GetPostInput = z.infer; export type IncrementPostReadInput = z.infer; -export type TogglePostLikeInput = z.infer; -export type AddTagsToPostInput = z.infer; -export type RemoveTagFromPostInput = z.infer; -export type UpdatePostTagsInput = z.infer; diff --git a/src/server/api/schemas/comment.ts b/src/server/api/schemas/comment.ts deleted file mode 100644 index 0108551..0000000 --- a/src/server/api/schemas/comment.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Comment API Validation Schemas - * - * Zod schemas for comment-related tRPC procedures: - * - Comment creation, updating, deletion - * - Comment reactions - * - Comment sorting and filtering - */ - -import { z } from "zod"; - -// ============================================================================ -// Comment CRUD Operations -// ============================================================================ - -/** - * Create new comment schema - */ -export const createCommentSchema = z.object({ - body: z - .string() - .min(1, "Comment cannot be empty") - .max(5000, "Comment too long"), - post_id: z.number(), - parent_comment_id: z.number().optional() -}); - -/** - * Update comment schema - */ -export const updateCommentSchema = z.object({ - commentId: z.number(), - body: z - .string() - .min(1, "Comment cannot be empty") - .max(5000, "Comment too long") -}); - -/** - * Delete comment schema - */ -export const deleteCommentSchema = z.object({ - commentId: z.number(), - deletionType: z.enum(["user", "admin", "database"]).optional() -}); - -/** - * Get comments for post schema - */ -export const getCommentsSchema = z.object({ - postId: z.number(), - sortBy: z.enum(["newest", "oldest", "highest_rated", "hot"]).default("newest") -}); - -// ============================================================================ -// Comment Reactions -// ============================================================================ - -/** - * Valid reaction types - */ -export const reactionTypeSchema = z.enum([ - "tears", - "blank", - "tongue", - "cry", - "heartEye", - "angry", - "moneyEye", - "sick", - "upsideDown", - "worried" -]); - -/** - * Add/remove reaction to comment - */ -export const toggleCommentReactionSchema = z.object({ - commentId: z.number(), - reactionType: reactionTypeSchema -}); - -/** - * Get reactions for comment - */ -export const getCommentReactionsSchema = z.object({ - commentId: z.number() -}); - -// ============================================================================ -// Comment Sorting -// ============================================================================ - -/** - * Valid comment sorting modes - */ -export const commentSortSchema = z - .enum(["newest", "oldest", "highest_rated", "hot"]) - .default("newest"); - -// ============================================================================ -// Type Exports -// ============================================================================ - -export type CommentSortMode = z.infer; -export type ReactionType = z.infer; -export type CreateCommentInput = z.infer; -export type UpdateCommentInput = z.infer; -export type DeleteCommentInput = z.infer; -export type GetCommentsInput = z.infer; -export type ToggleCommentReactionInput = z.infer< - typeof toggleCommentReactionSchema ->; -export type GetCommentReactionsInput = z.infer< - typeof getCommentReactionsSchema ->; diff --git a/src/server/api/schemas/database.ts b/src/server/api/schemas/database.ts index ad8d8aa..c0ad81d 100644 --- a/src/server/api/schemas/database.ts +++ b/src/server/api/schemas/database.ts @@ -7,71 +7,10 @@ import { z } from "zod"; * Use these schemas for validating database inputs and outputs in tRPC procedures */ -// ============================================================================ -// User Schemas -// ============================================================================ - -/** - * Full User schema matching database structure - */ -export const userSchema = z.object({ - id: z.string(), - email: z.string().email().nullable().optional(), - email_verified: z.number(), - password_hash: z.string().nullable().optional(), - display_name: z.string().nullable().optional(), - provider: z.enum(["email", "google", "github"]).nullable().optional(), - image: z.string().url().nullable().optional(), - apple_user_string: z.string().nullable().optional(), - database_name: z.string().nullable().optional(), - database_token: z.string().nullable().optional(), - database_url: z.string().nullable().optional(), - db_destroy_date: z.string().nullable().optional(), - created_at: z.string(), - updated_at: z.string() -}); - -/** - * User creation input (for registration) - */ -export const createUserSchema = z.object({ - email: z.string().email().optional(), - password: z.string().min(8).optional(), - display_name: z.string().min(1).max(50).optional(), - provider: z.enum(["email", "google", "github"]).optional(), - image: z.string().url().optional() -}); - -/** - * User update input (partial updates) - */ -export const updateUserSchema = z.object({ - email: z.string().email().optional(), - display_name: z.string().min(1).max(50).optional(), - image: z.string().url().optional() -}); - // ============================================================================ // Post Schemas // ============================================================================ -/** - * Full Post schema matching database structure - */ -export const postSchema = z.object({ - id: z.number(), - category: z.enum(["blog", "project"]), - title: z.string(), - subtitle: z.string().optional(), - body: z.string(), - banner_photo: z.string().optional(), - date: z.string(), - published: z.boolean(), - author_id: z.string(), - reads: z.number(), - attachments: z.string().optional() -}); - /** * Post creation input */ @@ -97,47 +36,6 @@ export const updatePostSchema = z.object({ attachments: z.string().optional() }); -/** - * Post with aggregated data - */ -export const postWithCommentsAndLikesSchema = postSchema.extend({ - total_likes: z.number(), - total_comments: z.number() -}); - -// ============================================================================ -// Comment Schemas -// ============================================================================ - -/** - * Full Comment schema matching database structure - */ -export const commentSchema = z.object({ - id: z.number(), - body: z.string(), - post_id: z.number(), - parent_comment_id: z.number().optional(), - date: z.string(), - edited: z.boolean(), - commenter_id: z.string() -}); - -/** - * Comment creation input - */ -export const createCommentSchema = z.object({ - body: z.string().min(1).max(5000), - post_id: z.number(), - parent_comment_id: z.number().optional() -}); - -/** - * Comment update input - */ -export const updateCommentSchema = z.object({ - body: z.string().min(1).max(5000) -}); - // ============================================================================ // CommentReaction Schemas // ============================================================================ @@ -160,94 +58,6 @@ export const reactionTypeSchema = z.enum([ "downVote" ]); -/** - * Full CommentReaction schema matching database structure - */ -export const commentReactionSchema = z.object({ - id: z.number(), - type: reactionTypeSchema, - comment_id: z.number(), - user_id: z.string() -}); - -/** - * Comment reaction creation input - */ -export const createCommentReactionSchema = z.object({ - type: reactionTypeSchema, - comment_id: z.number() -}); - -// ============================================================================ -// PostLike Schemas -// ============================================================================ - -/** - * Full PostLike schema matching database structure - */ -export const postLikeSchema = z.object({ - id: z.number(), - user_id: z.string(), - post_id: z.number() -}); - -/** - * PostLike creation input - */ -export const createPostLikeSchema = z.object({ - post_id: z.number() -}); - -// ============================================================================ -// Tag Schemas -// ============================================================================ - -/** - * Full Tag schema matching database structure - */ -export const tagSchema = z.object({ - id: z.number(), - value: z.string(), - post_id: z.number() -}); - -/** - * Tag creation input - */ -export const createTagSchema = z.object({ - value: z.string().min(1).max(50), - post_id: z.number() -}); - -/** - * PostWithTags schema - */ -export const postWithTagsSchema = postSchema.extend({ - tags: z.array(tagSchema) -}); - -// ============================================================================ -// Connection Schemas -// ============================================================================ - -/** - * Full Connection schema matching database structure - */ -export const connectionSchema = z.object({ - id: z.number(), - user_id: z.string(), - connection_id: z.string(), - post_id: z.number().optional() -}); - -/** - * Connection creation input - */ -export const createConnectionSchema = z.object({ - connection_id: z.string(), - post_id: z.number().optional() -}); - // ============================================================================ // Common Query Schemas // ============================================================================ @@ -259,26 +69,6 @@ export const idSchema = z.object({ id: z.number() }); -export const userIdSchema = z.object({ - userId: z.string() -}); - -export const postIdSchema = z.object({ - postId: z.number() -}); - -export const commentIdSchema = z.object({ - commentId: z.number() -}); - -/** - * Pagination schema - */ -export const paginationSchema = z.object({ - limit: z.number().min(1).max(100).default(10), - offset: z.number().min(0).default(0) -}); - // ============================================================================ // Additional Database Router Schemas // ============================================================================ @@ -343,10 +133,6 @@ export const getUserByIdSchema = z.object({ id: z.string() }); -export const getUserPublicDataSchema = z.object({ - id: z.string() -}); - export const updateUserImageSchema = z.object({ id: z.string(), imageURL: z.string() @@ -365,15 +151,6 @@ export const updateUserEmailSchema = z.object({ export type ReactionType = z.infer; export type CreatePostInput = z.infer; export type UpdatePostInput = z.infer; -export type CreateCommentInput = z.infer; -export type UpdateCommentInput = z.infer; -export type CreateCommentReactionInput = z.infer< - typeof createCommentReactionSchema ->; -export type CreatePostLikeInput = z.infer; -export type CreateTagInput = z.infer; -export type CreateConnectionInput = z.infer; -export type PaginationInput = z.infer; export type GetPostByIdInput = z.infer; export type GetPostByTitleInput = z.infer; export type GetCommentsByPostIdInput = z.infer< diff --git a/src/server/api/schemas/user.ts b/src/server/api/schemas/user.ts index 39a19f0..8ac6bc0 100644 --- a/src/server/api/schemas/user.ts +++ b/src/server/api/schemas/user.ts @@ -65,11 +65,6 @@ export const loginUserSchema = z.object({ rememberMe: z.boolean().optional().default(false) }); -/** - * OAuth provider schema - */ -export const oauthProviderSchema = z.enum(["google", "github"]); - // ============================================================================ // Profile Management Schemas // ============================================================================ @@ -168,20 +163,12 @@ export const deleteAccountSchema = z.object({ password: z.string().min(1, "Password is required to delete account") }); -/** - * Email verification schema - */ -export const verifyEmailSchema = z.object({ - token: z.string().min(1) -}); - // ============================================================================ // Type Exports // ============================================================================ export type RegisterUserInput = z.infer; export type LoginUserInput = z.infer; -export type OAuthProvider = z.infer; export type UpdateEmailInput = z.infer; export type UpdateDisplayNameInput = z.infer; export type UpdateProfileImageInput = z.infer; @@ -192,4 +179,3 @@ export type RequestPasswordResetInput = z.infer< >; export type ResetPasswordInput = z.infer; export type DeleteAccountInput = z.infer; -export type VerifyEmailInput = z.infer; diff --git a/src/server/audit.test.ts b/src/server/audit.test.ts index d5c84ca..1153b04 100644 --- a/src/server/audit.test.ts +++ b/src/server/audit.test.ts @@ -89,7 +89,6 @@ describe("Audit Logging System", () => { }); it("should not throw errors on logging failures", async () => { - // This should not throw even if there's an invalid event type await expect( logAuditEvent({ eventType: "invalid.test.event", @@ -101,7 +100,6 @@ describe("Audit Logging System", () => { describe("queryAuditLogs", () => { beforeEach(async () => { - // Create test logs await logAuditEvent({ eventType: "auth.login.success", eventData: { test: "test-query-1", testUser: "user-1" }, @@ -199,7 +197,6 @@ describe("Audit Logging System", () => { describe("getFailedLoginAttempts", () => { beforeEach(async () => { - // Create failed login attempts for (let i = 0; i < 5; i++) { await logAuditEvent({ eventType: "auth.login.failed", @@ -212,7 +209,6 @@ describe("Audit Logging System", () => { }); } - // Create successful logins (should be excluded) await logAuditEvent({ eventType: "auth.login.success", eventData: { test: "test-success-1" }, @@ -241,7 +237,6 @@ describe("Audit Logging System", () => { const attemptsIn1h = await getFailedLoginAttempts(1, 100); expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5); - // Recent attempts should be within 1 hour expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5); }); }); @@ -302,7 +297,6 @@ describe("Audit Logging System", () => { describe("detectSuspiciousActivity", () => { beforeEach(async () => { - // Create suspicious pattern: many failed logins from same IP for (let i = 0; i < 10; i++) { await logAuditEvent({ eventType: "auth.login.failed", @@ -315,7 +309,6 @@ describe("Audit Logging System", () => { }); } - // Create normal activity await logAuditEvent({ eventType: "auth.login.success", eventData: { test: "test-normal-1" }, @@ -344,7 +337,6 @@ describe("Audit Logging System", () => { it("should return empty array when no suspicious activity", async () => { await cleanupTestLogs(); - // Create only successful logins await logAuditEvent({ eventType: "auth.login.success", eventData: { test: "test-clean-1" }, @@ -378,7 +370,6 @@ describe("Audit Logging System", () => { ] }); - // Clean up logs older than 90 days const deleted = await cleanupOldLogs(90); expect(deleted).toBeGreaterThanOrEqual(1); @@ -399,7 +390,6 @@ describe("Audit Logging System", () => { const logsAfter = await queryAuditLogs({ limit: 100 }); - // Should still have recent logs expect(logsAfter.length).toBeGreaterThan(0); }); }); diff --git a/src/server/audit.ts b/src/server/audit.ts index e48e522..fd09ca6 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -405,7 +405,6 @@ export async function detectSuspiciousActivity( const currentIp = currentIpOrMinAttempts as string; const reasons: string[] = []; - // Check for excessive failed logins const failedAttempts = (await getFailedLoginAttempts( userId, "user_id", @@ -415,7 +414,6 @@ export async function detectSuspiciousActivity( reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`); } - // Check for rapid location changes (different IPs in short time) const recentIps = await conn.execute({ sql: `SELECT DISTINCT ip_address FROM AuditLog WHERE user_id = ? @@ -431,7 +429,6 @@ export async function detectSuspiciousActivity( ); } - // Check for new IP if user has login history const ipHistory = await conn.execute({ sql: `SELECT COUNT(*) as count FROM AuditLog WHERE user_id = ? diff --git a/src/server/cache.ts b/src/server/cache.ts index 87aa3c9..9439cfd 100644 --- a/src/server/cache.ts +++ b/src/server/cache.ts @@ -100,7 +100,6 @@ export async function withCacheAndStale( const now = Date.now(); const entry = store.get(key) as CacheEntry | undefined; - // Fresh hit if (entry && entry.expiresAt > now) return entry.data; try { @@ -116,7 +115,6 @@ export async function withCacheAndStale( console.error(`Error fetching data for cache key "${key}":`, error); } - // Stale fallback if (entry && entry.staleExpiresAt > now) { if (logErrors) console.log(`Serving stale data for cache key "${key}"`); return entry.data; diff --git a/src/server/clerk-user-webhook.test.ts b/src/server/clerk-user-webhook.test.ts index 1d2adff..753d080 100644 --- a/src/server/clerk-user-webhook.test.ts +++ b/src/server/clerk-user-webhook.test.ts @@ -254,7 +254,6 @@ describe("Clerk user.created webhook", () => { describe("Clerk user.updated webhook", () => { it("updates mutable fields and leaves clerkUserId unchanged", async () => { - // seed via created await call(sign(userCreatedPayload())); const before = getUserByClerkId("user_abc123"); diff --git a/src/server/device-utils.ts b/src/server/device-utils.ts index df076ea..b6c9052 100644 --- a/src/server/device-utils.ts +++ b/src/server/device-utils.ts @@ -1,6 +1,3 @@ -import type { H3Event } from "vinxi/http"; -import { UAParser } from "ua-parser-js"; - export interface DeviceInfo { deviceName?: string; deviceType?: "desktop" | "mobile" | "tablet"; @@ -8,61 +5,6 @@ export interface DeviceInfo { os?: string; } -/** - * Parse user agent string to extract device information - * @param userAgent - User agent string from request headers - * @returns Parsed device information - */ -export function parseDeviceInfo(userAgent: string): DeviceInfo { - const parser = new UAParser(userAgent); - const result = parser.getResult(); - - // Determine device type - let deviceType: "desktop" | "mobile" | "tablet" = "desktop"; - if (result.device.type === "mobile") { - deviceType = "mobile"; - } else if (result.device.type === "tablet") { - deviceType = "tablet"; - } - - // Build device name (e.g., "iPhone 14", "Windows PC", "iPad Pro") - let deviceName: string | undefined; - if (result.device.vendor && result.device.model) { - deviceName = `${result.device.vendor} ${result.device.model}`; - } else if (result.os.name) { - deviceName = `${result.os.name} ${deviceType === "desktop" ? "Computer" : deviceType}`; - } - - // Browser info (e.g., "Chrome 120") - const browser = - result.browser.name && result.browser.version - ? `${result.browser.name} ${result.browser.version.split(".")[0]}` - : result.browser.name; - - // OS info (e.g., "macOS 14.1", "Windows 11", "iOS 17") - const os = - result.os.name && result.os.version - ? `${result.os.name} ${result.os.version}` - : result.os.name; - - return { - deviceName, - deviceType, - browser, - os - }; -} - -/** - * Extract device information from H3Event - * @param event - H3Event - * @returns Device information - */ -export function getDeviceInfo(event: H3Event): DeviceInfo { - const userAgent = event.node.req.headers["user-agent"] || ""; - return parseDeviceInfo(userAgent); -} - /** * Generate a human-readable device description * @param deviceInfo - Device information @@ -85,18 +27,3 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string { return parts.length > 0 ? parts.join(" • ") : "Unknown Device"; } - -/** - * Create a short device fingerprint for comparison - * Not cryptographic, just for grouping similar logins - * @param deviceInfo - Device information - * @returns Short fingerprint string - */ -export function createDeviceFingerprint(deviceInfo: DeviceInfo): string { - const parts = [ - deviceInfo.deviceType || "unknown", - deviceInfo.os?.split(" ")[0] || "unknown", - deviceInfo.browser?.split(" ")[0] || "unknown" - ]; - return parts.join("-").toLowerCase(); -} diff --git a/src/server/email-templates/index.ts b/src/server/email-templates/index.ts index 63879fb..2014ce8 100644 --- a/src/server/email-templates/index.ts +++ b/src/server/email-templates/index.ts @@ -5,7 +5,6 @@ import loginLinkTemplate from "./login-link.html?raw"; import passwordResetTemplate from "./password-reset.html?raw"; import emailVerificationTemplate from "./email-verification.html?raw"; import providerLinkedTemplate from "./provider-linked.html?raw"; -import newDeviceLoginTemplate from "./new-device-login.html?raw"; import passwordSetTemplate from "./password-set.html?raw"; /** @@ -119,29 +118,6 @@ export function generateProviderLinkedEmail( }); } -export interface NewDeviceLoginEmailParams { - deviceInfo: string; - loginTime: string; - ipAddress: string; - loginMethod: string; - accountUrl: string; -} - -/** - * Generate new device login notification email HTML - */ -export function generateNewDeviceLoginEmail( - params: NewDeviceLoginEmailParams -): string { - return processTemplate(newDeviceLoginTemplate, { - DEVICE_INFO: params.deviceInfo, - LOGIN_TIME: params.loginTime, - IP_ADDRESS: params.ipAddress, - LOGIN_METHOD: params.loginMethod, - ACCOUNT_URL: params.accountUrl - }); -} - export interface PasswordSetEmailParams { providerName: string; setTime: string; diff --git a/src/server/fetch-utils.test.ts b/src/server/fetch-utils.test.ts index 4085dfe..521f56e 100644 --- a/src/server/fetch-utils.test.ts +++ b/src/server/fetch-utils.test.ts @@ -12,7 +12,6 @@ import { async function testTimeoutError() { console.log("\n=== Testing Timeout Error ==="); try { - // This should timeout after 1ms await fetchWithTimeout("https://httpbin.org/delay/10", { timeout: 1 }); console.log("❌ Should have thrown TimeoutError"); } catch (error) { @@ -29,7 +28,6 @@ async function testTimeoutError() { async function testNetworkError() { console.log("\n=== Testing Network Error ==="); try { - // This should fail to connect await fetchWithTimeout( "https://invalid-domain-that-does-not-exist-12345.com" ); @@ -47,7 +45,6 @@ async function testNetworkError() { async function testAPIError() { console.log("\n=== Testing API Error ==="); try { - // This should return 404 const response = await fetchWithTimeout("https://httpbin.org/status/404"); await checkResponse(response); console.log("❌ Should have thrown APIError"); diff --git a/src/server/middleare/security-headers.ts b/src/server/middleare/security-headers.ts deleted file mode 100644 index b08f61f..0000000 --- a/src/server/middleare/security-headers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineMiddleware, setHeaders } from "vinxi/http"; - -// Security headers middleware — sets CSP and hardening headers on all responses -export default defineMiddleware({ - onRequest: (event) => { - setHeaders(event, { - "Content-Security-Policy": - "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "strict-origin-when-cross-origin", - "Permissions-Policy": "camera=(), microphone=(), geolocation=()" - }); - } -}); diff --git a/src/server/nessa-auth.test.ts b/src/server/nessa-auth.test.ts index 849a9a2..1aa77ef 100644 --- a/src/server/nessa-auth.test.ts +++ b/src/server/nessa-auth.test.ts @@ -57,7 +57,6 @@ describe("verifyNessaToken with Clerk JWT", () => { expect(result.exp).toBe(mockPayload.exp); expect(result.iat).toBe(mockPayload.iat); - // Verify verifyToken was called with correct options expect(mockVerifyToken).toHaveBeenCalledWith( "valid-clerk-session-token", expect.objectContaining({ diff --git a/src/server/nessa-auth.ts b/src/server/nessa-auth.ts index cf370d9..1ac8d42 100644 --- a/src/server/nessa-auth.ts +++ b/src/server/nessa-auth.ts @@ -33,8 +33,6 @@ export async function verifyNessaToken( ): Promise { const payload = await verifyToken(token, { secretKey: env.NESSA_CLERK_SECRET, - // Optional: restrict to specific issuers / apps - // audience: env.NESSA_CLERK_JWT_ISSUER, }); if (!payload.sub) { diff --git a/src/server/provider-helpers.ts b/src/server/provider-helpers.ts index d669f77..047e790 100644 --- a/src/server/provider-helpers.ts +++ b/src/server/provider-helpers.ts @@ -34,7 +34,6 @@ export async function linkProvider( ): Promise { const conn = ConnectionFactory(); - // Check if provider already linked to this user const existing = await conn.execute({ sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?", args: [userId, provider] @@ -44,7 +43,6 @@ export async function linkProvider( throw new Error(`Provider ${provider} already linked to this account`); } - // Check if provider identity is already used by another user if (providerData.providerUserId) { const conflictCheck = await conn.execute({ sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?", @@ -61,7 +59,6 @@ export async function linkProvider( } } - // Create new provider link const id = uuidV4(); await conn.execute({ sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image) @@ -77,7 +74,6 @@ export async function linkProvider( ] }); - // Fetch created record const result = await conn.execute({ sql: "SELECT * FROM UserProvider WHERE id = ?", args: [id] @@ -85,7 +81,6 @@ export async function linkProvider( const userProvider = result.rows[0] as unknown as UserProvider; - // Log audit event await logAuditEvent({ userId, eventType: "auth.provider.linked", @@ -99,7 +94,6 @@ export async function linkProvider( // Send notification email if requested and user has email if (options?.sendEmail !== false) { try { - // Get user email const userResult = await conn.execute({ sql: "SELECT email FROM User WHERE id = ?", args: [userId] @@ -150,7 +144,6 @@ export async function unlinkProvider( ): Promise { const conn = ConnectionFactory(); - // Check how many providers this user has const providersResult = await conn.execute({ sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?", args: [userId] @@ -164,7 +157,6 @@ export async function unlinkProvider( ); } - // Delete provider const result = await conn.execute({ sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?", args: [userId, provider] @@ -174,7 +166,6 @@ export async function unlinkProvider( throw new Error(`Provider ${provider} not found for this user`); } - // Log audit event await logAuditEvent({ userId, eventType: "auth.provider.unlinked", @@ -261,7 +252,6 @@ export async function findUserByProviderEmail( export async function findUserByEmail(email: string): Promise { const conn = ConnectionFactory(); - // First check User table const userResult = await conn.execute({ sql: "SELECT id FROM User WHERE email = ?", args: [email] @@ -271,7 +261,6 @@ export async function findUserByEmail(email: string): Promise { return (userResult.rows[0] as any).id; } - // Then check UserProvider table const providerResult = await conn.execute({ sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1", args: [email] diff --git a/src/server/security/csrf.test.ts b/src/server/security/csrf.test.ts index 964b068..e003ff2 100644 --- a/src/server/security/csrf.test.ts +++ b/src/server/security/csrf.test.ts @@ -21,7 +21,6 @@ describe("CSRF Protection", () => { const token = generateCSRFToken(); expect(token).toBeDefined(); expect(typeof token).toBe("string"); - // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx expect(token).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i ); @@ -34,7 +33,6 @@ describe("CSRF Protection", () => { }); it("should generate cryptographically secure tokens", () => { - // Generate multiple tokens and ensure no collisions const tokens = new Set(); for (let i = 0; i < 1000; i++) { tokens.add(generateCSRFToken()); @@ -50,7 +48,6 @@ describe("CSRF Protection", () => { expect(token).toBeDefined(); expect(typeof token).toBe("string"); - // Token should be a UUID expect(token).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i ); @@ -122,7 +119,6 @@ describe("CSRF Protection", () => { const invalidToken1 = "b".repeat(36); const invalidToken2 = "b".repeat(35) + "a"; - // Test timing for completely different tokens const event1 = createMockEvent({ headers: { "x-csrf-token": invalidToken1 }, cookies: { "csrf-token": validToken } @@ -132,7 +128,6 @@ describe("CSRF Protection", () => { validateCSRFToken(event1); const time1 = performance.now() - start1; - // Test timing for tokens that differ only at the end const event2 = createMockEvent({ headers: { "x-csrf-token": invalidToken2 }, cookies: { "csrf-token": validToken } @@ -142,7 +137,6 @@ describe("CSRF Protection", () => { validateCSRFToken(event2); const time2 = performance.now() - start2; - // Timing difference should be minimal (less than 1ms) // This tests for constant-time comparison const timeDiff = Math.abs(time1 - time2); expect(timeDiff).toBeLessThan(1); @@ -161,7 +155,6 @@ describe("CSRF Protection", () => { describe("CSRF Attack Scenarios", () => { it("should prevent basic CSRF attack", () => { - // Attacker doesn't have access to the CSRF token cookie const attackEvent = createMockEvent({ headers: { "x-csrf-token": "attacker-guessed-token" } }); @@ -174,7 +167,6 @@ describe("CSRF Protection", () => { const token1 = generateCSRFToken(); const token2 = generateCSRFToken(); - // User has token1, attacker tries to use token2 const event = createMockEvent({ headers: { "x-csrf-token": token2 }, cookies: { "csrf-token": token1 } @@ -198,7 +190,6 @@ describe("CSRF Protection", () => { }); it("should prevent replay attacks with old tokens", () => { - // Simulate an old token that was captured const oldToken = "old-captured-token-12345"; const event = createMockEvent({ @@ -206,10 +197,8 @@ describe("CSRF Protection", () => { cookies: { "csrf-token": oldToken } }); - // Even if tokens match, they should be validated by the system - // This test validates the structure works correctly const isValid = validateCSRFToken(event); - expect(isValid).toBe(true); // Matches are valid + expect(isValid).toBe(true); }); }); @@ -270,9 +259,7 @@ describe("CSRF Protection", () => { tokens.push(generateCSRFToken()); } - // Check for sequential patterns for (let i = 1; i < tokens.length; i++) { - // Tokens should not be incrementing expect(tokens[i]).not.toBe( String(Number(tokens[i - 1].replace(/-/g, "")) + 1) ); @@ -281,11 +268,9 @@ describe("CSRF Protection", () => { it("should generate tokens with sufficient entropy", () => { const token = generateCSRFToken(); - // UUID without dashes should be 32 hex characters const hexString = token.replace(/-/g, ""); expect(hexString).toMatch(/^[0-9a-f]{32}$/i); - // Check that not all characters are the same const uniqueChars = new Set(hexString.split("")); expect(uniqueChars.size).toBeGreaterThan(5); }); @@ -299,7 +284,6 @@ describe("CSRF Protection", () => { } const duration = performance.now() - start; - // Should generate 1000 tokens in less than 100ms expect(duration).toBeLessThan(100); }); @@ -316,7 +300,6 @@ describe("CSRF Protection", () => { } const duration = performance.now() - start; - // Should validate 10000 tokens in less than 100ms expect(duration).toBeLessThan(100); }); }); @@ -402,7 +385,6 @@ describe("CSRF Protection", () => { const sessionAToken = generateCSRFToken(); const sessionBToken = generateCSRFToken(); - // Session A's cookie with Session B's header token const event = createMockEvent({ headers: { "x-csrf-token": sessionBToken }, cookies: { "csrf-token": sessionAToken } @@ -522,12 +504,10 @@ describe("CSRF Protection", () => { it("should issue CSRF token on setCSRFToken then validate it", () => { const event = createMockEvent({}); - // Step 1: Login issues CSRF token const token = setCSRFToken(event); expect(token).toBeDefined(); expect(typeof token).toBe("string"); - // Step 2: Subsequent mutation sends token back const mutationEvent = createMockEvent({ headers: { "x-csrf-token": token }, cookies: { "csrf-token": token } @@ -538,7 +518,6 @@ describe("CSRF Protection", () => { }); it("should reject cross-origin POST without CSRF token", () => { - // Simulated cross-site POST: attacker can read cookies but not set headers const attackEvent = createMockEvent({ // No x-csrf-token header (cross-origin requests can't set custom headers) cookies: { "csrf-token": "victim-token" } diff --git a/src/server/security/injection.test.ts b/src/server/security/injection.test.ts index 8555e55..bbf4dd3 100644 --- a/src/server/security/injection.test.ts +++ b/src/server/security/injection.test.ts @@ -59,7 +59,6 @@ describe("Input Validation and Injection Prevention", () => { for (const email of sqlEmails) { // Either reject as invalid, or it's properly escaped in queries const isValid = isValidEmail(email); - // Test documents the behavior expect(typeof isValid).toBe("boolean"); } }); @@ -68,7 +67,6 @@ describe("Input Validation and Injection Prevention", () => { const longEmail = "a".repeat(1000) + "@example.com"; const result = isValidEmail(longEmail); - // Should handle gracefully expect(typeof result).toBe("boolean"); }); @@ -130,7 +128,6 @@ describe("Input Validation and Injection Prevention", () => { it("should use parameterized queries for user authentication", async () => { const conn = ConnectionFactory(); - // Test that SQL injection attempts don't work const maliciousEmail = "admin'--"; try { @@ -140,7 +137,6 @@ describe("Input Validation and Injection Prevention", () => { args: [maliciousEmail] }); - // Should return no results (no user with that exact email) expect(result.rows.length).toBe(0); } catch (error) { // If error, ensure it's not a SQL error @@ -153,7 +149,6 @@ describe("Input Validation and Injection Prevention", () => { for (const payload of SQL_INJECTION_PAYLOADS) { try { - // Test various injection points await conn.execute({ sql: "SELECT * FROM User WHERE email = ?", args: [payload] @@ -164,7 +159,6 @@ describe("Input Validation and Injection Prevention", () => { args: [payload] }); - // Queries should complete without SQL errors expect(true).toBe(true); } catch (error: any) { // If error occurs, should not be SQL injection syntax error @@ -184,10 +178,8 @@ describe("Input Validation and Injection Prevention", () => { args: [unionPayload] }); - // Should not return password hashes if (result.rows.length > 0) { for (const row of result.rows) { - // Ensure we don't get password_hash column expect(row).not.toHaveProperty("password_hash"); } } @@ -200,7 +192,6 @@ describe("Input Validation and Injection Prevention", () => { it("should prevent blind SQL injection timing attacks", async () => { const conn = ConnectionFactory(); - // Timing-based payload const timingPayload = "admin' AND SLEEP(5)--"; const start = performance.now(); @@ -210,18 +201,15 @@ describe("Input Validation and Injection Prevention", () => { args: [timingPayload] }); } catch (error) { - // Ignore errors } const duration = performance.now() - start; - // Should not delay for 5 seconds expect(duration).toBeLessThan(1000); }); it("should prevent second-order SQL injection", async () => { const conn = ConnectionFactory(); - // Store malicious data const maliciousName = "admin'--"; try { @@ -236,7 +224,6 @@ describe("Input Validation and Injection Prevention", () => { ] }); - // Retrieve and use (should still be safe with parameterized queries) const result = await conn.execute({ sql: "SELECT display_name FROM User WHERE email = ?", args: ["test-sqli@example.com"] @@ -244,13 +231,11 @@ describe("Input Validation and Injection Prevention", () => { expect(result.rows.length).toBeGreaterThanOrEqual(0); - // Cleanup await conn.execute({ sql: "DELETE FROM User WHERE email = ?", args: ["test-sqli@example.com"] }); } catch (error) { - // Should not have SQL syntax errors expect(error).toBeDefined(); } }); @@ -260,7 +245,6 @@ describe("Input Validation and Injection Prevention", () => { it("should identify potentially dangerous XSS patterns", () => { // These payloads should be handled by frontend sanitization for (const payload of XSS_PAYLOADS) { - // Document that these patterns exist expect(payload).toBeDefined(); expect(typeof payload).toBe("string"); @@ -272,11 +256,9 @@ describe("Input Validation and Injection Prevention", () => { it("should handle script tags in user input", () => { const scriptInput = ""; - // Validation should not crash const nameValid = isValidDisplayName(scriptInput); expect(typeof nameValid).toBe("boolean"); - // Email validation const emailValid = isValidEmail(scriptInput); expect(typeof emailValid).toBe("boolean"); }); @@ -450,7 +432,6 @@ describe("Input Validation and Injection Prevention", () => { expect(typeof emailValid).toBe("boolean"); expect(typeof nameValid).toBe("boolean"); - // Should complete quickly (no ReDoS) expect(duration).toBeLessThan(100); }); @@ -508,14 +489,12 @@ describe("Input Validation and Injection Prevention", () => { }); it("should not be vulnerable to ReDoS attacks", () => { - // ReDoS payload with many repetitions const redosPayload = "a".repeat(1000) + "!"; const start = performance.now(); validatePassword(redosPayload); const duration = performance.now() - start; - // Should complete quickly expect(duration).toBeLessThan(100); }); }); diff --git a/src/server/security/password.test.ts b/src/server/security/password.test.ts index 98eca9e..9ce0fa4 100644 --- a/src/server/security/password.test.ts +++ b/src/server/security/password.test.ts @@ -20,7 +20,6 @@ describe("Password Security", () => { expect(hash).toBeDefined(); expect(typeof hash).toBe("string"); - // Bcrypt hashes start with $2b$ or $2a$ expect(hash).toMatch(/^\$2[ab]\$/); }); @@ -36,7 +35,6 @@ describe("Password Security", () => { const password = "TestPassword123!"; const hash = await hashPassword(password); - // Bcrypt hashes are 60 characters long expect(hash.length).toBe(60); }); @@ -132,32 +130,26 @@ describe("Password Security", () => { const password = "TestPassword123!"; const hash = await hashPassword(password); - // Measure time for correct password const { duration: correctDuration } = await measureTime(() => checkPasswordSafe(password, hash) ); - // Measure time for incorrect password const { duration: incorrectDuration } = await measureTime(() => checkPasswordSafe("WrongPassword123!", hash) ); - // Bcrypt comparison should take similar time regardless const timingDifference = Math.abs(correctDuration - incorrectDuration); - // Allow reasonable variance (bcrypt is inherently slow) expect(timingDifference).toBeLessThan(50); }); it("should handle null hash without timing leak", async () => { const password = "TestPassword123!"; - // Measure time for null hash const { result: result1, duration: duration1 } = await measureTime(() => checkPasswordSafe(password, null) ); - // Measure time for undefined hash const { result: result2, duration: duration2 } = await measureTime(() => checkPasswordSafe(password, undefined) ); @@ -165,7 +157,6 @@ describe("Password Security", () => { expect(result1).toBe(false); expect(result2).toBe(false); - // Should take similar time const timingDifference = Math.abs(duration1 - duration2); expect(timingDifference).toBeLessThan(50); }); @@ -178,7 +169,6 @@ describe("Password Security", () => { checkPasswordSafe(password, null) ); - // Should take at least a few milliseconds (bcrypt is slow) expect(duration).toBeGreaterThan(1); }); @@ -186,12 +176,10 @@ describe("Password Security", () => { const password = "TestPassword123!"; const hash = await hashPassword(password); - // User exists const { duration: existsDuration } = await measureTime(() => checkPasswordSafe("WrongPassword", hash) ); - // User doesn't exist (null hash) const { duration: notExistsDuration } = await measureTime(() => checkPasswordSafe("WrongPassword", null) ); @@ -280,9 +268,9 @@ describe("Password Security", () => { }); it("should calculate password strength correctly", () => { - const fairPassword = "MyP@ssw0rd12"; // 12 chars - const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars - const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars + const fairPassword = "MyP@ssw0rd12"; + const goodPassword = "MyStr0ng!P@ssw0rd"; + const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; expect(validatePassword(fairPassword).strength).toBe("fair"); expect(validatePassword(goodPassword).strength).toBe("good"); @@ -337,7 +325,6 @@ describe("Password Security", () => { const password = "TestPassword123!"; const hash = await hashPassword(password); - // Measure time for multiple checks (simulating brute force) const start = performance.now(); const attempts = 10; @@ -348,7 +335,6 @@ describe("Password Security", () => { const duration = performance.now() - start; const avgPerAttempt = duration / attempts; - // Each attempt should take significant time (bcrypt is slow) // This makes brute force impractical expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt }); @@ -356,18 +342,15 @@ describe("Password Security", () => { it("should prevent rainbow table attacks with unique salts", async () => { const password = "CommonPassword123!"; - // Generate multiple hashes for same password const hashes = await Promise.all( Array.from({ length: 10 }, () => hashPassword(password)) ); - // All hashes should be unique (different salts) const uniqueHashes = new Set(hashes); expect(uniqueHashes.size).toBe(10); }); it("should prevent password spraying with validation", () => { - // Common passwords that should be rejected const commonPasswords = [ "Password123!", "Welcome123!", @@ -382,7 +365,6 @@ describe("Password Security", () => { }); it("should resist dictionary attacks", () => { - // Dictionary words that should be caught const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"]; for (const password of dictionaryBased) { @@ -394,7 +376,7 @@ describe("Password Security", () => { describe("Edge Cases", () => { it("should handle very long passwords", async () => { - const longPassword = "A1!a" + "x".repeat(1000); // Very long but valid + const longPassword = "A1!a" + "x".repeat(1000); const hash = await hashPassword(longPassword); const match = await checkPassword(longPassword, hash); @@ -413,7 +395,6 @@ describe("Password Security", () => { const hash = await hashPassword(nullBytePassword); const match = await checkPassword(nullBytePassword, hash); - // Behavior may vary - just ensure no crash expect(typeof match).toBe("boolean"); }); @@ -450,7 +431,6 @@ describe("Password Security", () => { const duration = performance.now() - start; // Bcrypt should be slow enough to deter brute force - // With 10 rounds, should take at least a few milliseconds expect(duration).toBeGreaterThan(5); // But not too slow for normal operation expect(duration).toBeLessThan(500); @@ -467,11 +447,9 @@ describe("Password Security", () => { durations.push(performance.now() - start); } - // Timing should be relatively consistent const avg = durations.reduce((a, b) => a + b, 0) / durations.length; const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg))); - // Allow reasonable variance expect(maxDeviation).toBeLessThan(avg * 0.5); }); @@ -484,7 +462,6 @@ describe("Password Security", () => { } const duration = performance.now() - start; - // Validation is CPU-bound but should be fast expect(duration).toBeLessThan(100); }); }); @@ -494,12 +471,9 @@ describe("Password Security", () => { const password = "TestPassword123!"; const hash = await hashPassword(password); - // Check that hash uses correct salt rounds - // Bcrypt format: $2b$rounds$salthash const parts = hash.split("$"); const rounds = parseInt(parts[2]); - // Should use 10 rounds (from password.ts) expect(rounds).toBe(10); }); @@ -509,17 +483,14 @@ describe("Password Security", () => { Array.from({ length: 100 }, () => hashPassword(password)) ); - // Extract salts from hashes const salts = hashes.map((hash) => { const parts = hash.split("$"); return parts[3].substring(0, 22); // Salt is 22 characters }); - // All salts should be unique const uniqueSalts = new Set(salts); expect(uniqueSalts.size).toBe(100); - // Check for patterns in salts (should be random) for (let i = 1; i < salts.length; i++) { // Salts should not be sequential or predictable expect(salts[i]).not.toBe(salts[i - 1]); diff --git a/src/server/security/rate-limit.test.ts b/src/server/security/rate-limit.test.ts index a2bbf14..595a6c5 100644 --- a/src/server/security/rate-limit.test.ts +++ b/src/server/security/rate-limit.test.ts @@ -60,12 +60,10 @@ describe("Rate Limiting", () => { const maxAttempts = 3; const windowMs = 60000; - // Use up all attempts for (let i = 0; i < maxAttempts; i++) { await checkRateLimit(identifier, maxAttempts, windowMs); } - // Next attempt should throw try { await checkRateLimit(identifier, maxAttempts, windowMs); expect.unreachable("Should have thrown"); @@ -79,7 +77,6 @@ describe("Rate Limiting", () => { const maxAttempts = 2; const windowMs = 60000; - // Use up all attempts await checkRateLimit(identifier, maxAttempts, windowMs); await checkRateLimit(identifier, maxAttempts, windowMs); @@ -99,12 +96,10 @@ describe("Rate Limiting", () => { const maxAttempts = 3; const windowMs = 500; // 500ms window for testing - // Use up all attempts for (let i = 0; i < maxAttempts; i++) { await checkRateLimit(identifier, maxAttempts, windowMs); } - // Should be blocked immediately after try { await checkRateLimit(identifier, maxAttempts, windowMs); expect.unreachable("Should have thrown"); @@ -112,10 +107,8 @@ describe("Rate Limiting", () => { expect(error).toBeInstanceOf(TRPCError); } - // Wait for window to expire await new Promise((resolve) => setTimeout(resolve, 600)); - // Should be allowed again const remaining = await checkRateLimit(identifier, maxAttempts, windowMs); expect(remaining).toBe(maxAttempts - 1); }); @@ -125,13 +118,11 @@ describe("Rate Limiting", () => { const maxAttempts = 10; const windowMs = 60000; - // Simulate concurrent requests const results: number[] = []; for (let i = 0; i < maxAttempts; i++) { results.push(await checkRateLimit(identifier, maxAttempts, windowMs)); } - // All should succeed with decreasing remaining counts expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); }); @@ -142,12 +133,10 @@ describe("Rate Limiting", () => { const id1 = uniqueId("test1"); const id2 = uniqueId("test2"); - // Use up attempts for id1 for (let i = 0; i < maxAttempts; i++) { await checkRateLimit(id1, maxAttempts, windowMs); } - // id1 should be blocked try { await checkRateLimit(id1, maxAttempts, windowMs); expect.unreachable("Should have thrown"); @@ -155,7 +144,6 @@ describe("Rate Limiting", () => { expect(error).toBeInstanceOf(TRPCError); } - // id2 should still work const remaining = await checkRateLimit(id2, maxAttempts, windowMs); expect(remaining).toBe(maxAttempts - 1); }); @@ -225,12 +213,10 @@ describe("Rate Limiting", () => { const email = `test-${Date.now()}@example.com`; // IP rate limiting is skipped in test/dev, so only email limit applies - // Use up email rate limit with same email for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) { await rateLimitLogin(email, ip); } - // Next attempt should fail due to email limit try { await rateLimitLogin(email, ip); expect.unreachable("Should have thrown"); @@ -242,12 +228,10 @@ describe("Rate Limiting", () => { it("should limit by email independently of IP", async () => { const email = `test-${Date.now()}@example.com`; - // Use different IPs but same email for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) { await rateLimitLogin(email, randomIP()); } - // Next attempt with different IP should still fail due to email limit try { await rateLimitLogin(email, randomIP()); expect.unreachable("Should have thrown"); @@ -260,13 +244,11 @@ describe("Rate Limiting", () => { const ip = randomIP(); // In test/dev, IP rate limiting is skipped - // Should allow many different emails from same IP for (let i = 0; i < 10; i++) { const email = `test${i}-${Date.now()}@example.com`; await rateLimitLogin(email, ip); } - // Should not throw since IP limits are disabled in test/dev expect(true).toBe(true); }); }); @@ -276,12 +258,10 @@ describe("Rate Limiting", () => { const ip = randomIP(); // IP rate limiting is skipped in test/dev - // Should allow many attempts for (let i = 0; i < 10; i++) { await rateLimitPasswordReset(ip); } - // Should not throw in test/dev expect(true).toBe(true); }); @@ -304,12 +284,10 @@ describe("Rate Limiting", () => { const ip = randomIP(); // IP rate limiting is skipped in test/dev - // Should allow many attempts for (let i = 0; i < 10; i++) { await rateLimitRegistration(ip); } - // Should not throw in test/dev expect(true).toBe(true); }); }); @@ -319,12 +297,10 @@ describe("Rate Limiting", () => { const ip = randomIP(); // IP rate limiting is skipped in test/dev - // Should allow many attempts for (let i = 0; i < 10; i++) { await rateLimitEmailVerification(ip); } - // Should not throw in test/dev expect(true).toBe(true); }); }); @@ -334,7 +310,6 @@ describe("Rate Limiting", () => { const email = "victim@example.com"; const attackerIP = "1.2.3.4"; - // Simulate brute force attack let blockedAtAttempt = 0; for (let i = 0; i < 10; i++) { try { @@ -347,7 +322,6 @@ describe("Rate Limiting", () => { } } - // Should be blocked before 10 attempts expect(blockedAtAttempt).toBeLessThan(10); expect(blockedAtAttempt).toBeGreaterThan(0); }); @@ -355,7 +329,6 @@ describe("Rate Limiting", () => { it("should prevent distributed brute force from multiple IPs", async () => { const email = "victim@example.com"; - // Simulate distributed attack from different IPs let blockedAtAttempt = 0; for (let i = 0; i < 10; i++) { try { @@ -368,7 +341,6 @@ describe("Rate Limiting", () => { } } - // Should be blocked at email limit (3 attempts) expect(blockedAtAttempt).toBeLessThanOrEqual( RATE_LIMITS.LOGIN_EMAIL.maxAttempts ); @@ -383,7 +355,6 @@ describe("Rate Limiting", () => { await rateLimitRegistration(attackerIP); } - // Should not block in test/dev (IP limits disabled) expect(true).toBe(true); }); @@ -396,7 +367,6 @@ describe("Rate Limiting", () => { await rateLimitPasswordReset(attackerIP); } - // Should not block in test/dev (IP limits disabled) expect(true).toBe(true); }); }); @@ -408,14 +378,12 @@ describe("Rate Limiting", () => { const unknownIP = "unknown"; const email = `test-${Date.now()}@example.com`; - // Should allow many login attempts in development with unknown IP // (only email rate limit applies) for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) { const testEmail = `test-${Date.now()}-${i}@example.com`; await rateLimitLogin(testEmail, unknownIP); } - // Should be able to continue with different emails (no IP limit in dev) await rateLimitLogin(`final-${Date.now()}@example.com`, unknownIP); }); @@ -423,12 +391,10 @@ describe("Rate Limiting", () => { const unknownIP = "unknown"; const email = `test-${Date.now()}@example.com`; - // Use up email rate limit for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) { await rateLimitLogin(email, unknownIP); } - // Next attempt should fail due to email limit try { await rateLimitLogin(email, unknownIP); expect.unreachable("Should have thrown"); @@ -440,55 +406,46 @@ describe("Rate Limiting", () => { it("should handle unknown IP in password reset", async () => { const unknownIP = "unknown"; - // In development, should allow many attempts (no IP limit) for (let i = 0; i < 10; i++) { await rateLimitPasswordReset(unknownIP); } - // Should not throw in development expect(true).toBe(true); }); it("should handle unknown IP in registration", async () => { const unknownIP = "unknown"; - // In development, should allow many attempts (no IP limit) for (let i = 0; i < 10; i++) { await rateLimitRegistration(unknownIP); } - // Should not throw in development expect(true).toBe(true); }); it("should handle unknown IP in email verification", async () => { const unknownIP = "unknown"; - // In development, should allow many attempts (no IP limit) for (let i = 0; i < 10; i++) { await rateLimitEmailVerification(unknownIP); } - // Should not throw in development expect(true).toBe(true); }); }); describe("Rate Limit Configuration", () => { it("should have reasonable limits configured", () => { - // Login should be more permissive than registration expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan( RATE_LIMITS.REGISTRATION_IP.maxAttempts ); - // All limits should be positive expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0); expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0); expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0); expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0); expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0); - // All windows should be at least 1 minute expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000); expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000); expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual( @@ -552,7 +509,6 @@ describe("Rate Limiting", () => { const maxAttempts = 3; const windowMs = 60000; - // Exhaust the limit: 3 allowed, 4th blocked. for (let i = 0; i < maxAttempts; i++) { await checkRateLimit(id, maxAttempts, windowMs); } @@ -574,7 +530,6 @@ describe("Rate Limiting", () => { const maxAttempts = 5; const windowMs = 60000; - // Instance A: 3 attempts. clearRateLimitLocalCache(); for (let i = 0; i < 3; i++) { await checkRateLimit(id, maxAttempts, windowMs); @@ -582,9 +537,9 @@ describe("Rate Limiting", () => { // Instance B (fresh local cache) makes 2 more -> combined count = 5. clearRateLimitLocalCache(); - await checkRateLimit(id, maxAttempts, windowMs); // count 4 - const remaining = await checkRateLimit(id, maxAttempts, windowMs); // count 5 - expect(remaining).toBe(0); // 5th allowed, no remaining + await checkRateLimit(id, maxAttempts, windowMs); + const remaining = await checkRateLimit(id, maxAttempts, windowMs); + expect(remaining).toBe(0); // A 6th attempt from a fresh instance must be blocked — the shared store // aggregated the count across the two "instances". diff --git a/src/server/security/test-utils.ts b/src/server/security/test-utils.ts index f9664bb..caac49d 100644 --- a/src/server/security/test-utils.ts +++ b/src/server/security/test-utils.ts @@ -4,8 +4,6 @@ */ import type { H3Event } from "vinxi/http"; -import { SignJWT } from "jose"; -import { env } from "~/env/server"; /** * Create a mock H3Event for testing @@ -62,55 +60,6 @@ export function createMockEvent(options: { return mockEvent; } -/** - * Generate a valid JWT token for testing - */ -export async function createTestJWT( - userId: string, - expiresIn: string = "1h" -): Promise { - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - return await new SignJWT({ id: userId }) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime(expiresIn) - .sign(secret); -} - -/** - * Generate an expired JWT token for testing - */ -export async function createExpiredJWT(userId: string): Promise { - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - return await new SignJWT({ id: userId }) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("-1h") // Expired 1 hour ago - .sign(secret); -} - -/** - * Generate a JWT with invalid signature - */ -export async function createInvalidSignatureJWT( - userId: string -): Promise { - const wrongSecret = new TextEncoder().encode("wrong-secret-key"); - return await new SignJWT({ id: userId }) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("1h") - .sign(wrongSecret); -} - -/** - * Generate test credentials - */ -export function createTestCredentials() { - return { - email: `test-${Date.now()}@example.com`, - password: "TestPass123!@#", - passwordConfirmation: "TestPass123!@#" - }; -} - /** * Common SQL injection payloads */ @@ -138,26 +87,6 @@ export const XSS_PAYLOADS = [ "" ]; -/** - * Wait for async operations with timeout - */ -export async function waitFor( - condition: () => boolean | Promise, - timeout: number = 5000, - interval: number = 100 -): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - if (await condition()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, interval)); - } - - throw new Error(`Timeout waiting for condition after ${timeout}ms`); -} - /** * Measure execution time */ @@ -170,18 +99,6 @@ export async function measureTime( return { result, duration }; } -/** - * Generate random string for testing - */ -export function randomString(length: number = 10): string { - const chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - return Array.from( - { length }, - () => chars[Math.floor(Math.random() * chars.length)] - ).join(""); -} - /** * Generate random IP address */