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
This commit is contained in:
2026-08-11 13:36:18 -04:00
parent 33ca9213f2
commit 898c891bd5
76 changed files with 1437 additions and 2600 deletions

2
.gitignore vendored
View File

@@ -31,3 +31,5 @@ perf-results-*.json
# System Files
.DS_Store
Thumbs.db
# pygienium run-state and check artifacts
.pygienium/

BIN
bun.lockb

Binary file not shown.

View File

@@ -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"
}
}

View File

@@ -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(

View File

@@ -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<PerformanceMetrics> {
// 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, "-")

View File

@@ -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();

View File

@@ -460,7 +460,7 @@ function MainRightBarContent() {
);
}
export function RightBarContent() {
function RightBarContent() {
const site = useSite();
return (
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>

View File

@@ -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) {

View File

@@ -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
}
});
},
{

View File

@@ -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);

View File

@@ -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 (

View File

@@ -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) => {

View File

@@ -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");

View File

@@ -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<number> => {
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(" ", "_"),

View File

@@ -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));

View File

@@ -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) {

View File

@@ -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<typeof setInterval> | null = null;
const observer = new IntersectionObserver(
(entries) => {

View File

@@ -1,29 +0,0 @@
const BackArrow = (props: {
height: number;
width: number;
stroke: string;
strokeWidth: number;
class?: string;
}) => {
return (
<div class={props.class}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={props.strokeWidth}
stroke={props.stroke}
height={props.height}
width={props.width}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
</div>
);
};
export default BackArrow;

View File

@@ -1,39 +0,0 @@
function MenuBars() {
return (
<svg
width="36"
height="30"
viewBox="0 0 120 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Mask group">
<g id="Frame 1">
<rect width="120" height="100" />
<line
id="LineA"
x1="11.5"
y1="31.5"
x2="108.5"
y2="31.5"
strokeWidth="6"
strokeLinecap="round"
class="stroke-black dark:stroke-white"
/>
<line
id="LineB"
x1="11.5"
y1="64.5"
x2="108.5"
y2="64.5"
strokeWidth="6"
strokeLinecap="round"
class="stroke-black dark:stroke-white"
/>
</g>
</g>
</svg>
);
}
export default MenuBars;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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];

View File

@@ -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;

View File

@@ -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);
`
};

View File

@@ -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);

11
src/env/client.ts vendored
View File

@@ -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() === "";

View File

@@ -80,7 +80,6 @@ export const getUserState = query(async (): Promise<UserState> => {
* 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)

View File

@@ -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<Response> {
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

View File

@@ -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=/`;
}

View File

@@ -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`;
}

View File

@@ -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<boolean> {
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<boolean> {
return true;
}
// Store current ETag for future checks
if (newEtag) {
sessionStorage.setItem("app-etag", newEtag);
}
@@ -80,7 +76,6 @@ async function checkForNewVersion(): Promise<boolean> {
* 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);

View File

@@ -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 = {};
}

View File

@@ -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);

View File

@@ -22,7 +22,7 @@ function xmlEscape(s: string): string {
/**
* Generate a single `<url>` 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 ` <url>
<loc>${xmlEscape(loc)}</loc>

View File

@@ -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('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain(
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
);
// 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("</urlset>");
const urlOpens = (xml.match(/<url>/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("</urlset>");
const urlOpens = (xml.match(/<url>/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");

View File

@@ -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();
});

View File

@@ -512,37 +512,142 @@ export default function AccountPage() {
Account Settings
</div>
{/* Account Type Section */}
<AccountTypeSection
profile={userProfile}
getProviderColor={getProviderColor}
getProviderName={getProviderName}
/>
<hr class="mx-auto mb-8 max-w-4xl" />
<ProfileImageSection
profile={userProfile}
handleImageDrop={handleImageDrop}
profileImageHolder={profileImageHolder}
preSetHolder={preSetHolder}
removeImage={removeImage}
setUserImage={setUserImage}
profileImageSetLoading={profileImageSetLoading}
profileImageStateChange={profileImageStateChange}
showImageSuccess={showImageSuccess}
/>
<hr class="mx-auto mb-8 max-w-4xl" />
{/* Email Section */}
<div class="mx-auto flex max-w-4xl flex-col gap-6 md:grid md:grid-cols-2">
<EmailSection
profile={userProfile}
emailRef={(el) => (emailRef = el)}
emailButtonLoading={emailButtonLoading}
setEmailTrigger={setEmailTrigger}
showEmailSuccess={showEmailSuccess}
sendEmailVerification={sendEmailVerification}
/>
{/* Display Name Section */}
<DisplayNameSection
profile={userProfile}
displayNameRef={(el) => (displayNameRef = el)}
displayNameButtonLoading={displayNameButtonLoading}
setDisplayNameTrigger={setDisplayNameTrigger}
showDisplayNameSuccess={showDisplayNameSuccess}
/>
</div>
<PasswordSection
profile={userProfile}
handlePasswordSubmit={handlePasswordSubmit}
oldPasswordRef={(el) => (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}
/>
<hr class="mt-8 mb-8" />
<LinkedProvidersSection profile={userProfile} />
<hr class="mt-8 mb-8" />
<SignOutSection
handleSignOut={handleSignOut}
signOutLoading={signOutLoading}
/>
<hr class="mt-8 mb-8" />
<DeleteAccountSection
profile={userProfile}
getProviderName={getProviderName}
deleteAccountPasswordRef={(el) =>
(deleteAccountPasswordRef = el)
}
deleteAccountButtonLoading={deleteAccountButtonLoading}
deleteAccountTrigger={deleteAccountTrigger}
passwordDeletionError={passwordDeletionError}
/>
</>
)}
</Show>
</div>
</div>
</>
);
}
function AccountTypeSection(props: {
profile: () => UserProfile;
getProviderColor: (provider: UserProfile["provider"]) => string;
getProviderName: (provider: UserProfile["provider"]) => string;
}) {
const { profile, getProviderColor, getProviderName } = props;
return (
<div class="mx-auto mb-8 max-w-md">
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
<div class="text-subtext0 mb-2 text-center text-sm font-semibold tracking-wide uppercase">
Account Type
</div>
<div class="flex items-center justify-center gap-3">
<span class={getProviderColor(userProfile().provider)}>
<Show when={userProfile().provider === "google"}>
<span class={getProviderColor(profile().provider)}>
<Show when={profile().provider === "google"}>
<GoogleLogo height={24} width={24} />
</Show>
<Show when={userProfile().provider === "github"}>
<Show when={profile().provider === "github"}>
<GitHub height={24} width={24} fill="currentColor" />
</Show>
<Show
when={
userProfile().provider === "email" ||
!userProfile().provider
profile().provider === "email" ||
!profile().provider
}
>
<EmailIcon height={24} width={24} />
</Show>
</span>
<span class="text-lg font-semibold">
{getProviderName(userProfile().provider)} Account
{getProviderName(profile().provider)} Account
</span>
</div>
<Show
when={
userProfile().provider !== "email" &&
!userProfile().email
profile().provider !== "email" &&
!profile().email
}
>
<div class="bg-yellow mt-3 rounded px-3 py-2 text-center text-base text-sm">
@@ -551,24 +656,47 @@ export default function AccountPage() {
</Show>
<Show
when={
userProfile().provider !== "email" &&
!userProfile().hasPassword
profile().provider !== "email" &&
!profile().hasPassword
}
>
<div class="bg-blue mt-3 rounded px-3 py-2 text-center text-base text-sm">
{!userProfile().email
{!profile().email
? "💡 Add and verify an email to enable email/password login"
: !userProfile().emailVerified
: !profile().emailVerified
? "💡 Verify your email to enable password setup"
: "💡 Add a password to enable email/password login"}
</div>
</Show>
</div>
</div>
);
}
<hr class="mx-auto mb-8 max-w-4xl" />
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;
{/* Profile Image Section */}
return (
<div class="mx-auto mb-8 flex max-w-md justify-center">
<div class="flex flex-col py-4">
<div class="mb-2 text-center text-lg font-semibold">
@@ -584,7 +712,7 @@ export default function AccountPage() {
onDrop={handleImageDrop}
acceptedFiles="image/jpg, image/jpeg, image/png"
fileHolder={profileImageHolder()}
preSet={preSetHolder() || userProfile().image || null}
preSet={preSetHolder() || profile().image || null}
/>
<button
type="button"
@@ -624,31 +752,46 @@ export default function AccountPage() {
/>
</div>
</div>
);
}
<hr class="mx-auto mb-8 max-w-4xl" />
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;
{/* Email Section */}
<div class="mx-auto flex max-w-4xl flex-col gap-6 md:grid md:grid-cols-2">
return (
<>
<div class="flex items-center justify-center text-lg md:justify-normal">
<div class="flex flex-col lg:flex-row">
<div class="pr-1 font-semibold whitespace-nowrap">
{userProfile().provider === "email"
{profile().provider === "email"
? "Email:"
: "Linked Email:"}
</div>
{userProfile().email ? (
<span>{userProfile().email}</span>
{profile().email ? (
<span>{profile().email}</span>
) : (
<span class="font-light italic underline underline-offset-4">
{userProfile().provider === "email"
{profile().provider === "email"
? "None Set"
: "Not Linked"}
</span>
)}
</div>
<Show
when={userProfile().email && !userProfile().emailVerified}
>
<Show when={profile().email && !profile().emailVerified}>
<button
onClick={sendEmailVerification}
class="text-red ml-2 text-sm underline transition-all hover:brightness-125"
@@ -670,12 +813,12 @@ export default function AccountPage() {
required
disabled={emailButtonLoading()}
title="Please enter a valid email address"
label={userProfile().email ? "Update Email" : "Add Email"}
label={profile().email ? "Update Email" : "Add Email"}
/>
<Show
when={
userProfile().provider !== "email" &&
!userProfile().email
profile().provider !== "email" &&
!profile().email
}
>
<div class="text-subtext0 mt-1 px-4 text-xs">
@@ -686,8 +829,8 @@ export default function AccountPage() {
<Button
type="submit"
disabled={
userProfile().email !== null &&
!userProfile().emailVerified
profile().email !== null &&
!profile().emailVerified
}
loading={emailButtonLoading()}
class="mt-2"
@@ -702,15 +845,34 @@ export default function AccountPage() {
class="mt-2"
/>
</form>
</>
);
}
{/* Display Name Section */}
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 (
<>
<div class="flex items-center justify-center text-lg md:justify-normal">
<div class="flex flex-col lg:flex-row">
<div class="pr-1 font-semibold whitespace-nowrap">
Display Name:
</div>
{userProfile().displayName ? (
<span>{userProfile().displayName}</span>
{profile().displayName ? (
<span>{profile().displayName}</span>
) : (
<span class="font-light italic underline underline-offset-4">
None Set
@@ -731,7 +893,7 @@ export default function AccountPage() {
required
disabled={displayNameButtonLoading()}
title="Please enter your display name"
label={`Set ${userProfile().displayName ? "New " : ""}Display Name`}
label={`Set ${profile().displayName ? "New " : ""}Display Name`}
containerClass="input-group mx-4"
/>
<div class="flex justify-end">
@@ -750,30 +912,71 @@ export default function AccountPage() {
class="mt-2"
/>
</form>
</div>
</>
);
}
{/* Password Change/Set Section */}
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 (
<form
onSubmit={handlePasswordSubmit}
class="mt-8 flex w-full justify-center"
>
<div class="flex w-full max-w-md flex-col justify-center">
<div class="mb-2 text-center text-xl font-semibold">
{userProfile().hasPassword
{profile().hasPassword
? "Change Password"
: "Add Password"}
</div>
<noscript>
<div class="text-subtext0 mb-4 text-center text-sm">
JavaScript required to{" "}
{userProfile().hasPassword ? "change" : "add"} password
{profile().hasPassword ? "change" : "add"} password
</div>
</noscript>
<Show when={!userProfile().hasPassword}>
<Show when={!profile().hasPassword}>
<Show
when={
userProfile().provider !== "email" &&
(!userProfile().email || !userProfile().emailVerified)
profile().provider !== "email" &&
(!profile().email || !profile().emailVerified)
}
>
<div class="bg-yellow mb-4 rounded px-4 py-3 text-center text-base text-sm">
@@ -781,14 +984,14 @@ export default function AccountPage() {
Email Verification Required
</div>
<div>
{!userProfile().email
{!profile().email
? "Please add and verify an email address before setting a password."
: "Please verify your email address before setting a password."}
</div>
<Show
when={
userProfile().email &&
!userProfile().emailVerified
profile().email &&
!profile().emailVerified
}
>
<button
@@ -802,21 +1005,21 @@ export default function AccountPage() {
</Show>
<Show
when={
userProfile().provider === "email" ||
(userProfile().email && userProfile().emailVerified)
profile().provider === "email" ||
(profile().email && profile().emailVerified)
}
>
<div class="text-subtext0 mb-4 text-center text-sm">
{userProfile().provider === "email"
{profile().provider === "email"
? "Set a password to enable password login"
: "Add a password to enable email/password login alongside your " +
getProviderName(userProfile().provider) +
getProviderName(profile().provider) +
" login"}
</div>
</Show>
</Show>
<Show when={userProfile().hasPassword}>
<Show when={profile().hasPassword}>
<PasswordInput
ref={oldPasswordRef}
required
@@ -835,10 +1038,10 @@ export default function AccountPage() {
onBlur={handlePasswordBlur}
disabled={
passwordChangeLoading() ||
(!userProfile().hasPassword &&
userProfile().provider !== "email" &&
(!userProfile().email ||
!userProfile().emailVerified))
(!profile().hasPassword &&
profile().provider !== "email" &&
(!profile().email ||
!profile().emailVerified))
}
title="Password must be at least 8 characters"
label="New Password"
@@ -852,10 +1055,10 @@ export default function AccountPage() {
onInput={handlePasswordConfChange}
disabled={
passwordChangeLoading() ||
(!userProfile().hasPassword &&
userProfile().provider !== "email" &&
(!userProfile().email ||
!userProfile().emailVerified))
(!profile().hasPassword &&
profile().provider !== "email" &&
(!profile().email ||
!profile().emailVerified))
}
title="Password must be at least 8 characters"
label="New Password Conf."
@@ -865,8 +1068,8 @@ export default function AccountPage() {
when={
!passwordsMatch() &&
passwordLengthSufficient() &&
newPasswordConfRef &&
newPasswordConfRef.value.length >= 6
newPasswordConfRef() &&
newPasswordConfRef()!.value.length >= 6
}
>
<FormFeedback
@@ -880,10 +1083,10 @@ export default function AccountPage() {
type="submit"
disabled={
!passwordsMatch() ||
(!userProfile().hasPassword &&
userProfile().provider !== "email" &&
(!userProfile().email ||
!userProfile().emailVerified))
(!profile().hasPassword &&
profile().provider !== "email" &&
(!profile().email ||
!profile().emailVerified))
}
loading={passwordChangeLoading()}
class="my-6"
@@ -894,7 +1097,7 @@ export default function AccountPage() {
<FormFeedback
type="error"
message={
userProfile().hasPassword
profile().hasPassword
? "Password did not match record"
: "Must have email & password provider linked or set password first"
}
@@ -903,27 +1106,36 @@ export default function AccountPage() {
<FormFeedback
type="success"
message={`Password ${userProfile().hasPassword ? "changed" : "set"} successfully!`}
message={`Password ${profile().hasPassword ? "changed" : "set"} successfully!`}
show={showPasswordSuccess()}
/>
</div>
</form>
);
}
<hr class="mt-8 mb-8" />
function LinkedProvidersSection(props: { profile: () => UserProfile }) {
const { profile } = props;
{/* Linked Providers Section */}
return (
<div class="mx-auto max-w-2xl py-8">
<div class="mb-6 text-center text-2xl font-semibold">
Linked Authentication Methods
</div>
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
<LinkedProviders userId={userProfile().id} />
<LinkedProviders userId={profile().id} />
</div>
</div>
);
}
<hr class="mt-8 mb-8" />
function SignOutSection(props: {
handleSignOut: () => void;
signOutLoading: () => boolean;
}) {
const { handleSignOut, signOutLoading } = props;
{/* Sign Out Section */}
return (
<div class="mx-auto max-w-md py-4">
<Button
type="button"
@@ -935,10 +1147,27 @@ export default function AccountPage() {
Sign Out
</Button>
</div>
);
}
<hr class="mt-8 mb-8" />
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;
{/* Delete Account Section */}
return (
<div class="mx-auto max-w-2xl py-8">
<div class="bg-red w-full rounded-md px-6 pt-8 pb-4 shadow-md brightness-75">
<div class="pb-4 text-center text-xl font-semibold">
@@ -956,11 +1185,11 @@ export default function AccountPage() {
</noscript>
<Show
when={userProfile().hasPassword}
when={profile().hasPassword}
fallback={
<div class="flex flex-col items-center">
<div class="text-crust mb-4 text-center text-sm">
Your {getProviderName(userProfile().provider)}{" "}
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.
@@ -1007,12 +1236,6 @@ export default function AccountPage() {
</Show>
</div>
</div>
</>
)}
</Show>
</div>
</div>
</>
);
}

View File

@@ -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, {

View File

@@ -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, {

View File

@@ -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");

View File

@@ -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(
`
<!DOCTYPE html>
@@ -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 =

View File

@@ -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,

View File

@@ -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<T = any>(event: APIEvent): Promise<T> {
return await event.request.json();
}

View File

@@ -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<Record<string, boolean>>(
{
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 }));
});
});

View File

@@ -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,18 +147,12 @@ export default function LoginPage() {
}
});
const formHandler = async (e: Event) => {
e.preventDefault();
setLoading(true);
setError("");
setShowPasswordError(false);
setShowPasswordSuccess(false);
const isRateLimited = (errorCode: string | undefined, message: string) =>
errorCode === "TOO_MANY_REQUESTS" || message.includes("Too many attempts");
try {
if (register()) {
const submitRegister = async () => {
if (!emailRef || !passwordRef || !passwordConfRef) {
setError("Please fill in all fields");
setLoading(false);
return;
}
@@ -170,20 +162,17 @@ export default function LoginPage() {
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;
}
@@ -201,17 +190,16 @@ export default function LoginPage() {
if (response.ok && result.result?.data) {
navigate("/account", { replace: true });
} else {
return;
}
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")
) {
if (isRateLimited(errorCode, errorMsg)) {
setError(errorMsg);
} else if (
errorMsg.includes("duplicate") ||
@@ -225,11 +213,11 @@ export default function LoginPage() {
} else {
setError(errorMsg);
}
}
} else if (usePassword()) {
};
const submitPasswordLogin = async () => {
if (!emailRef || !passwordRef || !rememberMeRef) {
setError("Please fill in all fields");
setLoading(false);
return;
}
@@ -251,14 +239,13 @@ export default function LoginPage() {
setTimeout(() => {
navigate("/account", { replace: true });
}, 500);
} else {
return;
}
const errorMessage = result.error?.message || "";
const errorCode = result.error?.data?.code;
if (
errorCode === "TOO_MANY_REQUESTS" ||
errorMessage.includes("Too many attempts")
) {
if (isRateLimited(errorCode, errorMessage)) {
setError(errorMessage);
} else if (
errorCode === "FORBIDDEN" ||
@@ -269,11 +256,11 @@ export default function LoginPage() {
} else {
setShowPasswordError(true);
}
}
} else {
};
const submitEmailLink = async () => {
if (!emailRef || !rememberMeRef) {
setError("Please enter your email");
setLoading(false);
return;
}
@@ -282,7 +269,6 @@ export default function LoginPage() {
if (!isValidEmail(email)) {
setError("Invalid email address");
setLoading(false);
return;
}
@@ -302,7 +288,9 @@ export default function LoginPage() {
Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS
);
startCountdown(expirationTime);
} else {
return;
}
const errorMsg =
result.error?.message ||
result.result?.data?.message ||
@@ -310,9 +298,8 @@ export default function LoginPage() {
const errorCode = result.error?.data?.code;
if (
errorCode === "TOO_MANY_REQUESTS" ||
errorMsg.includes("countdown not expired") ||
errorMsg.includes("Too many attempts")
isRateLimited(errorCode, errorMsg) ||
errorMsg.includes("countdown not expired")
) {
setError(
errorMsg.includes("countdown")
@@ -332,7 +319,22 @@ export default function LoginPage() {
} else {
setError(errorMsg);
}
}
};
const formHandler = async (e: Event) => {
e.preventDefault();
setLoading(true);
setError("");
setShowPasswordError(false);
setShowPasswordSuccess(false);
try {
if (register()) {
await submitRegister();
} else if (usePassword()) {
await submitPasswordLogin();
} else {
await submitEmailLink();
}
} catch (err: any) {
console.error("Login error:", err);

View File

@@ -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) {

View File

@@ -117,14 +117,11 @@ function scheduleAnalyticsFlush(): void {
*/
export async function logVisit(entry: AnalyticsEntry): Promise<void> {
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,

View File

@@ -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")
) {

View File

@@ -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 });

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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
]
});
});
});

View File

@@ -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<typeof userInputSchema>[]
) {
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<typeof exerciseLibrarySchema>[]
) {
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<typeof workoutPlanSchema>[]
) {
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<typeof planExerciseSchema>[]
) {
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<typeof planSetSchema>[]
) {
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<typeof routePointSchema>[]
) {
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<typeof workoutSchema>[]
) {
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<typeof heartRateSchema>[]
) {
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<typeof locationSampleSchema>[]
) {
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<typeof workoutSplitSchema>[]
) {
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<typeof providerSchema>[]
) {
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) {

View File

@@ -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]

View File

@@ -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

View File

@@ -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<typeof postCategorySchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type DeletePostInput = z.infer<typeof deletePostSchema>;
export type PostSortMode = z.infer<typeof postSortModeSchema>;
export type PostQueryInput = z.infer<typeof postQueryInputSchema>;
export type GetPostInput = z.infer<typeof getPostSchema>;
export type IncrementPostReadInput = z.infer<typeof incrementPostReadSchema>;
export type TogglePostLikeInput = z.infer<typeof togglePostLikeSchema>;
export type AddTagsToPostInput = z.infer<typeof addTagsToPostSchema>;
export type RemoveTagFromPostInput = z.infer<typeof removeTagFromPostSchema>;
export type UpdatePostTagsInput = z.infer<typeof updatePostTagsSchema>;

View File

@@ -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<typeof commentSortSchema>;
export type ReactionType = z.infer<typeof reactionTypeSchema>;
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
export type DeleteCommentInput = z.infer<typeof deleteCommentSchema>;
export type GetCommentsInput = z.infer<typeof getCommentsSchema>;
export type ToggleCommentReactionInput = z.infer<
typeof toggleCommentReactionSchema
>;
export type GetCommentReactionsInput = z.infer<
typeof getCommentReactionsSchema
>;

View File

@@ -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<typeof reactionTypeSchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
export type CreateCommentReactionInput = z.infer<
typeof createCommentReactionSchema
>;
export type CreatePostLikeInput = z.infer<typeof createPostLikeSchema>;
export type CreateTagInput = z.infer<typeof createTagSchema>;
export type CreateConnectionInput = z.infer<typeof createConnectionSchema>;
export type PaginationInput = z.infer<typeof paginationSchema>;
export type GetPostByIdInput = z.infer<typeof getPostByIdSchema>;
export type GetPostByTitleInput = z.infer<typeof getPostByTitleSchema>;
export type GetCommentsByPostIdInput = z.infer<

View File

@@ -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<typeof registerUserSchema>;
export type LoginUserInput = z.infer<typeof loginUserSchema>;
export type OAuthProvider = z.infer<typeof oauthProviderSchema>;
export type UpdateEmailInput = z.infer<typeof updateEmailSchema>;
export type UpdateDisplayNameInput = z.infer<typeof updateDisplayNameSchema>;
export type UpdateProfileImageInput = z.infer<typeof updateProfileImageSchema>;
@@ -192,4 +179,3 @@ export type RequestPasswordResetInput = z.infer<
>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type DeleteAccountInput = z.infer<typeof deleteAccountSchema>;
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;

View File

@@ -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);
});
});

View File

@@ -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 = ?

View File

@@ -100,7 +100,6 @@ export async function withCacheAndStale<T>(
const now = Date.now();
const entry = store.get(key) as CacheEntry<T> | undefined;
// Fresh hit
if (entry && entry.expiresAt > now) return entry.data;
try {
@@ -116,7 +115,6 @@ export async function withCacheAndStale<T>(
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;

View File

@@ -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");

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -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=()"
});
}
});

View File

@@ -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({

View File

@@ -33,8 +33,6 @@ export async function verifyNessaToken(
): Promise<NessaAuthPayload> {
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) {

View File

@@ -34,7 +34,6 @@ export async function linkProvider(
): Promise<UserProvider> {
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<void> {
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<string | null> {
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<string | null> {
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]

View File

@@ -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<string>();
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" }

View File

@@ -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 = "<script>alert('XSS')</script>";
// 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);
});
});

View File

@@ -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]);

View File

@@ -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".

View File

@@ -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<string> {
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<string> {
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<string> {
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 = [
"<input onfocus=alert('XSS') autofocus>"
];
/**
* Wait for async operations with timeout
*/
export async function waitFor(
condition: () => boolean | Promise<boolean>,
timeout: number = 5000,
interval: number = 100
): Promise<void> {
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<T>(
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
*/