From 898c891bd58b4c07bf7022b4347dde60b6b1cf1b Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 13:36:18 -0400 Subject: [PATCH] chore: remediate pygienium audit findings Dead code: 77 verified-unused exports, files (BackArrow, MenuBars, cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and 12 unused dependencies removed Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose blocks tightened; dead commented-out config removed Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10); login formHandler split into 3 submitters (CCN 63->~5); account page render split into 8 section components (CCN 40); updatePost SQL builder rebuilt; assert*Owned consolidated behind generic assertOwnedBy Defensive guards: 4 redundant rethrow/nullish guards removed --- .gitignore | 2 + bun.lockb | Bin 509837 -> 432942 bytes package.json | 14 +- scripts/perf-compare.ts | 6 - scripts/perf-test.ts | 37 - src/app.tsx | 2 - src/components/Bars.tsx | 2 +- src/components/ContactForm.tsx | 4 - src/components/Typewriter.tsx | 3 - src/components/blog/AddAttachmentSection.tsx | 2 - src/components/blog/CommentSectionWrapper.tsx | 8 - src/components/blog/MermaidRenderer.tsx | 2 - src/components/blog/PostBodyClient.tsx | 12 - src/components/blog/PostForm.tsx | 7 - src/components/blog/PostSorting.tsx | 2 +- src/components/blog/TextEditor.tsx | 5 - src/components/blog/extensions/Mermaid.ts | 5 - src/components/icons/BackArrow.tsx | 29 - src/components/icons/MenuBars.tsx | 39 - src/components/ui/Button.tsx | 1 - src/config.ts | 73 - src/context/SiteContext.tsx | 2 +- src/context/auth.tsx | 3 - src/db/create.ts | 160 --- src/entry-client.tsx | 8 - src/env/client.ts | 11 +- src/lib/auth-query.ts | 1 - src/lib/client-utils.ts | 15 - src/lib/cookies.ts | 111 -- src/lib/date-utils.ts | 1 - src/lib/deployment-detection.ts | 10 - src/lib/performance-tracking.ts | 9 - src/lib/s3upload.ts | 1 - src/lib/sitemap-generate.ts | 2 +- src/lib/sitemap-routes.test.ts | 6 - src/lib/useCountdown.ts | 4 - src/routes/account.tsx | 1171 ++++++++++------- src/routes/api/Gaze/appcast.xml.ts | 1 - src/routes/api/InputHalo/appcast.xml.ts | 1 - src/routes/api/auth/email-login-callback.ts | 1 - .../api/auth/email-verification-callback.ts | 4 - src/routes/api/downloads/[filename].ts | 4 - src/routes/api/lineage/_lib.ts | 5 - src/routes/downloads.tsx | 5 - src/routes/login/index.tsx | 352 ++--- src/routes/test.tsx | 1 - src/server/analytics.ts | 5 - src/server/api/routers/auth.ts | 50 +- src/server/api/routers/database.ts | 28 +- src/server/api/routers/downloads.ts | 2 - src/server/api/routers/git-activity.ts | 2 - src/server/api/routers/misc.ts | 10 +- .../api/routers/nessa-ownership.test.ts | 60 +- src/server/api/routers/nessa.ts | 871 ++++++------ src/server/api/routers/post-history.ts | 5 +- src/server/api/routers/user.ts | 1 - src/server/api/schemas/blog.ts | 146 -- src/server/api/schemas/comment.ts | 116 -- src/server/api/schemas/database.ts | 223 ---- src/server/api/schemas/user.ts | 14 - src/server/audit.test.ts | 10 - src/server/audit.ts | 3 - src/server/cache.ts | 2 - src/server/clerk-user-webhook.test.ts | 1 - src/server/device-utils.ts | 73 - src/server/email-templates/index.ts | 24 - src/server/fetch-utils.test.ts | 3 - src/server/middleare/security-headers.ts | 15 - src/server/nessa-auth.test.ts | 1 - src/server/nessa-auth.ts | 2 - src/server/provider-helpers.ts | 11 - src/server/security/csrf.test.ts | 23 +- src/server/security/injection.test.ts | 21 - src/server/security/password.test.ts | 37 +- src/server/security/rate-limit.test.ts | 51 +- src/server/security/test-utils.ts | 83 -- 76 files changed, 1437 insertions(+), 2600 deletions(-) delete mode 100644 src/components/icons/BackArrow.tsx delete mode 100644 src/components/icons/MenuBars.tsx delete mode 100644 src/db/create.ts delete mode 100644 src/lib/cookies.ts delete mode 100644 src/server/api/schemas/comment.ts delete mode 100644 src/server/middleare/security-headers.ts 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 bef2dfe11aae7bcd1fc47121b6d4f349e4ea53d0..1aafab591ed25d7490c3f7175824b4c3951ee65e 100755 GIT binary patch delta 76318 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 z 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 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 */