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 # System Files
.DS_Store .DS_Store
Thumbs.db 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", "@aws-sdk/s3-request-presigner": "^3.953.0",
"@clerk/backend": "^3.12.0", "@clerk/backend": "^3.12.0",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@motionone/solid": "^10.16.4",
"@sentry/solidstart": "^10.67.0", "@sentry/solidstart": "^10.67.0",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0", "@solidjs/router": "^0.15.0",
@@ -26,13 +25,11 @@
"@tailwindcss/vite": "^4.0.7", "@tailwindcss/vite": "^4.0.7",
"@tiptap/core": "^3.14.0", "@tiptap/core": "^3.14.0",
"@tiptap/extension-code-block-lowlight": "^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": "^3.14.0",
"@tiptap/extension-details-content": "^2.26.2", "@tiptap/extension-details-content": "^2.26.2",
"@tiptap/extension-details-summary": "^2.26.2", "@tiptap/extension-details-summary": "^2.26.2",
"@tiptap/extension-image": "^3.14.0", "@tiptap/extension-image": "^3.14.0",
"@tiptap/extension-link": "^3.14.0", "@tiptap/extension-link": "^3.14.0",
"@tiptap/extension-list-item": "^3.14.0",
"@tiptap/extension-subscript": "^3.14.0", "@tiptap/extension-subscript": "^3.14.0",
"@tiptap/extension-superscript": "^3.14.0", "@tiptap/extension-superscript": "^3.14.0",
"@tiptap/extension-table": "^3.14.0", "@tiptap/extension-table": "^3.14.0",
@@ -42,13 +39,11 @@
"@tiptap/extension-task-item": "^3.14.0", "@tiptap/extension-task-item": "^3.14.0",
"@tiptap/extension-task-list": "^3.14.0", "@tiptap/extension-task-list": "^3.14.0",
"@tiptap/extension-text-align": "^3.14.0", "@tiptap/extension-text-align": "^3.14.0",
"@tiptap/extension-text-style": "^3.14.0",
"@tiptap/pm": "^3.14.0", "@tiptap/pm": "^3.14.0",
"@tiptap/starter-kit": "^3.14.0", "@tiptap/starter-kit": "^3.14.0",
"@trpc/client": "^10.45.2", "@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2", "@trpc/server": "^10.45.2",
"@tursodatabase/api": "^1.9.2", "@tursodatabase/api": "^1.9.2",
"@typeschema/valibot": "^0.13.4",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"fast-diff": "^1.3.0", "fast-diff": "^1.3.0",
@@ -69,18 +64,11 @@
"node": "24.x" "node": "24.x"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0",
"@sentry/vite-plugin": "^5.4.0", "@sentry/vite-plugin": "^5.4.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/fast-diff": "^1.2.2",
"chrome-launcher": "^1.2.1",
"lighthouse": "^13.0.1",
"playwright": "^1.57.0", "playwright": "^1.57.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2"
"rollup-plugin-visualizer": "^6.0.5",
"trpc-panel": "^1.3.4",
"vite-bundle-visualizer": "^1.2.1"
} }
} }

View File

@@ -96,7 +96,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
"───────────────────────────────────────────────────────────────────\n" "───────────────────────────────────────────────────────────────────\n"
); );
// Compare each page
for (const baseResult of baseline.results) { for (const baseResult of baseline.results) {
const optResult = optimized.results.find((r) => r.page === baseResult.page); const optResult = optimized.results.find((r) => r.page === baseResult.page);
if (!optResult) continue; if (!optResult) continue;
@@ -107,7 +106,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
console.log(`\n📄 ${baseResult.page}`); console.log(`\n📄 ${baseResult.page}`);
console.log("─".repeat(70)); console.log("─".repeat(70));
// Core Web Vitals
console.log("\n Core Web Vitals:"); console.log("\n Core Web Vitals:");
const fcpDiff = opt.fcp - base.fcp; 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")})` ` CLS: ${base.cls.toFixed(3)}${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
); );
// Loading Metrics
console.log("\n Loading Metrics:"); console.log("\n Loading Metrics:");
const ttfbDiff = opt.ttfb - base.ttfb; 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)}` ` Load: ${formatTime(base.loadComplete)}${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
); );
// Resource Loading
console.log("\n Resources:"); console.log("\n Resources:");
const reqDiff = opt.totalRequests - base.totalRequests; const reqDiff = opt.totalRequests - base.totalRequests;
@@ -185,7 +181,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
); );
} }
// Overall Summary
console.log( console.log(
"\n\n═══════════════════════════════════════════════════════════════════" "\n\n═══════════════════════════════════════════════════════════════════"
); );
@@ -338,7 +333,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
); );
} }
// Specific findings
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests); const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
if (reqPercent < -5) { if (reqPercent < -5) {
console.log( 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 RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
const WARMUP_RUNS = 1; const WARMUP_RUNS = 1;
// Pages to test
const TEST_PAGES: PageTestConfig[] = [ const TEST_PAGES: PageTestConfig[] = [
{ name: "Home", path: "/" }, { name: "Home", path: "/" },
{ name: "Blog Index", path: "/blog" }, { name: "Blog Index", path: "/blog" },
@@ -78,7 +77,6 @@ const TEST_PAGES: PageTestConfig[] = [
{ name: "404", path: "/404" } { name: "404", path: "/404" }
]; ];
// Add additional blog post path if provided
if (process.env.TEST_BLOG_POST) { if (process.env.TEST_BLOG_POST) {
TEST_PAGES.push({ TEST_PAGES.push({
name: "Custom Blog Post", name: "Custom Blog Post",
@@ -102,7 +100,6 @@ async function setupPerformanceObservers(page: Page) {
interactions: [] as number[] interactions: [] as number[]
}; };
// Observe LCP
if ("PerformanceObserver" in window) { if ("PerformanceObserver" in window) {
try { try {
const lcpObserver = new PerformanceObserver((entryList) => { const lcpObserver = new PerformanceObserver((entryList) => {
@@ -118,10 +115,8 @@ async function setupPerformanceObservers(page: Page) {
buffered: true buffered: true
}); });
} catch (e) { } catch (e) {
// LCP not supported
} }
// Observe CLS
try { try {
const clsObserver = new PerformanceObserver((entryList) => { const clsObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) { for (const entry of entryList.getEntries()) {
@@ -138,10 +133,8 @@ async function setupPerformanceObservers(page: Page) {
}); });
clsObserver.observe({ type: "layout-shift", buffered: true }); clsObserver.observe({ type: "layout-shift", buffered: true });
} catch (e) { } catch (e) {
// CLS not supported
} }
// Observe FID (first input)
try { try {
const fidObserver = new PerformanceObserver((entryList) => { const fidObserver = new PerformanceObserver((entryList) => {
const firstInput = entryList.getEntries()[0] as any; const firstInput = entryList.getEntries()[0] as any;
@@ -154,10 +147,8 @@ async function setupPerformanceObservers(page: Page) {
}); });
fidObserver.observe({ type: "first-input", buffered: true }); fidObserver.observe({ type: "first-input", buffered: true });
} catch (e) { } catch (e) {
// FID not supported
} }
// Observe long tasks
try { try {
const longTaskObserver = new PerformanceObserver((entryList) => { const longTaskObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) { for (const entry of entryList.getEntries()) {
@@ -166,10 +157,8 @@ async function setupPerformanceObservers(page: Page) {
}); });
longTaskObserver.observe({ type: "longtask", buffered: true }); longTaskObserver.observe({ type: "longtask", buffered: true });
} catch (e) { } catch (e) {
// Long tasks not supported
} }
// Observe INP (event timing for interactions)
try { try {
const inpObserver = new PerformanceObserver((entryList) => { const inpObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) { for (const entry of entryList.getEntries()) {
@@ -194,7 +183,6 @@ async function setupPerformanceObservers(page: Page) {
}); });
inpObserver.observe({ type: "event", buffered: true }); inpObserver.observe({ type: "event", buffered: true });
} catch (e) { } catch (e) {
// Event timing not supported
} }
} }
}); });
@@ -203,18 +191,14 @@ async function setupPerformanceObservers(page: Page) {
async function collectPerformanceMetrics( async function collectPerformanceMetrics(
page: Page page: Page
): Promise<PerformanceMetrics> { ): Promise<PerformanceMetrics> {
// Wait for page to be loaded
await page.waitForLoadState("load"); await page.waitForLoadState("load");
// Wait a bit longer for LCP to settle (it can change as content loads)
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
// Additional wait for any remaining network activity
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => { await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
// Ignore timeout - networkidle may never happen for some pages // Ignore timeout - networkidle may never happen for some pages
}); });
// Collect comprehensive performance metrics
const metrics = await page.evaluate(() => { const metrics = await page.evaluate(() => {
const perf = performance.getEntriesByType( const perf = performance.getEntriesByType(
"navigation" "navigation"
@@ -222,7 +206,6 @@ async function collectPerformanceMetrics(
const paint = performance.getEntriesByType("paint"); const paint = performance.getEntriesByType("paint");
const fcp = paint.find((entry) => entry.name === "first-contentful-paint"); const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
// Get metrics from our observers
const observedMetrics = (window as any).__perfMetrics || { const observedMetrics = (window as any).__perfMetrics || {
lcp: 0, lcp: 0,
cls: 0, cls: 0,
@@ -232,7 +215,6 @@ async function collectPerformanceMetrics(
interactions: [] interactions: []
}; };
// Fallback to direct API if observers didn't capture anything
let lcp = observedMetrics.lcp; let lcp = observedMetrics.lcp;
let cls = observedMetrics.cls; let cls = observedMetrics.cls;
let fid = observedMetrics.fid; let fid = observedMetrics.fid;
@@ -258,7 +240,6 @@ async function collectPerformanceMetrics(
.reduce((sum: number, entry: any) => sum + entry.value, 0); .reduce((sum: number, entry: any) => sum + entry.value, 0);
} }
// Calculate INP from event timing entries if not already captured
if (inp === 0) { if (inp === 0) {
const eventEntries = performance.getEntriesByType("event") as any[]; const eventEntries = performance.getEntriesByType("event") as any[];
const interactionLatencies = eventEntries const interactionLatencies = eventEntries
@@ -275,7 +256,6 @@ async function collectPerformanceMetrics(
} }
} }
// Get resource timing
const resources = performance.getEntriesByType( const resources = performance.getEntriesByType(
"resource" "resource"
) as PerformanceResourceTiming[]; ) as PerformanceResourceTiming[];
@@ -318,7 +298,6 @@ async function collectPerformanceMetrics(
} }
}); });
// Calculate long task duration
let taskDuration = 0; let taskDuration = 0;
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) { if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
taskDuration = observedMetrics.longTasks.reduce( taskDuration = observedMetrics.longTasks.reduce(
@@ -327,7 +306,6 @@ async function collectPerformanceMetrics(
); );
} }
// Get more granular performance entries
let jsExecutionTime = 0; let jsExecutionTime = 0;
let layoutDuration = 0; let layoutDuration = 0;
let paintDuration = 0; let paintDuration = 0;
@@ -339,7 +317,6 @@ async function collectPerformanceMetrics(
} }
}); });
// Check for script evaluation entries
const entries = performance.getEntries(); const entries = performance.getEntries();
entries.forEach((entry: any) => { entries.forEach((entry: any) => {
if (entry.entryType === "measure") { if (entry.entryType === "measure") {
@@ -399,7 +376,6 @@ async function testPagePerformance(
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n` ` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
); );
// Warmup runs (not counted)
for (let i = 0; i < WARMUP_RUNS; i++) { for (let i = 0; i < WARMUP_RUNS; i++) {
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
@@ -410,20 +386,16 @@ async function testPagePerformance(
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`); console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
} }
// Measured runs
for (let i = 0; i < RUNS_PER_PAGE; i++) { for (let i = 0; i < RUNS_PER_PAGE; i++) {
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`); console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
// Create new context for each run to ensure clean state
const context = await browser.newContext({ const context = await browser.newContext({
viewport: { width: 1920, height: 1080 } viewport: { width: 1920, height: 1080 }
}); });
const page = await context.newPage(); const page = await context.newPage();
// Setup performance observers before navigation
await setupPerformanceObservers(page); await setupPerformanceObservers(page);
// Navigate and collect metrics
await page.goto(url, { waitUntil: "load" }); await page.goto(url, { waitUntil: "load" });
const metrics = await collectPerformanceMetrics(page); const metrics = await collectPerformanceMetrics(page);
@@ -436,7 +408,6 @@ async function testPagePerformance(
); );
} }
// Calculate statistics
const average = calculateAverage(runs); const average = calculateAverage(runs);
const median = calculateMedian(runs); const median = calculateMedian(runs);
const p95 = calculatePercentile(runs, 95); const p95 = calculatePercentile(runs, 95);
@@ -648,7 +619,6 @@ function printResults(results: TestResult[]) {
"═══════════════════════════════════════════════════════════════════\n" "═══════════════════════════════════════════════════════════════════\n"
); );
// Overall averages
const overallAverage = { const overallAverage = {
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length, lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
fcp: results.reduce((sum, r) => sum + r.median.fcp, 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:"); console.log("\n Optimization Opportunities:");
// Find pages with highest JS bytes
const highestJS = [...results].sort( const highestJS = [...results].sort(
(a, b) => b.median.jsBytes - a.median.jsBytes (a, b) => b.median.jsBytes - a.median.jsBytes
)[0]; )[0];
@@ -707,7 +676,6 @@ function printResults(results: TestResult[]) {
); );
} }
// Find pages with slow LCP
const slowLCP = results.filter((r) => r.median.lcp > 2500); const slowLCP = results.filter((r) => r.median.lcp > 2500);
if (slowLCP.length > 0) { if (slowLCP.length > 0) {
console.log( 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); const highCLS = results.filter((r) => r.median.cls > 0.1);
if (highCLS.length > 0) { if (highCLS.length > 0) {
console.log( console.log(
@@ -723,7 +690,6 @@ function printResults(results: TestResult[]) {
); );
} }
// Find pages with high INP
const highINP = results.filter((r) => r.median.inp > 200); const highINP = results.filter((r) => r.median.inp > 200);
if (highINP.length > 0) { if (highINP.length > 0) {
console.log( console.log(
@@ -740,7 +706,6 @@ async function main() {
console.log(`Pages to test: ${TEST_PAGES.length}`); console.log(`Pages to test: ${TEST_PAGES.length}`);
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`); console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
// Check if server is running
try { try {
const response = await fetch(BASE_URL); const response = await fetch(BASE_URL);
if (!response.ok) { if (!response.ok) {
@@ -770,10 +735,8 @@ async function main() {
await browser.close(); await browser.close();
// Print results
printResults(results); printResults(results);
// Save results to JSON file
const timestamp = new Date() const timestamp = new Date()
.toISOString() .toISOString()
.replace(/[:.]/g, "-") .replace(/[:.]/g, "-")

View File

@@ -35,10 +35,8 @@ function AppLayout(props: { children: any }) {
let lastScrollY = 0; let lastScrollY = 0;
onMount(() => { onMount(() => {
// Initialize performance tracking
initPerformanceTracking(); initPerformanceTracking();
// Start monitoring for new deployments
startDeploymentMonitoring(); startDeploymentMonitoring();
const windowWidth = createWindowWidth(); const windowWidth = createWindowWidth();

View File

@@ -460,7 +460,7 @@ function MainRightBarContent() {
); );
} }
export function RightBarContent() { function RightBarContent() {
const site = useSite(); const site = useSite();
return ( return (
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}> <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"); const { env } = await import("~/env/server");
// Verify Cloudflare Turnstile token
const turnstileValid = await verifyTurnstileToken( const turnstileValid = await verifyTurnstileToken(
turnstileToken, turnstileToken,
env.TURNSTILE_SECRET_KEY, env.TURNSTILE_SECRET_KEY,
@@ -252,7 +251,6 @@ export function ContactForm(props: ContactFormProps) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Load server data using createAsync
const contactData = createAsync(() => getContactData(), { const contactData = createAsync(() => getContactData(), {
deferStream: true deferStream: true
}); });
@@ -350,7 +348,6 @@ export function ContactForm(props: ContactFormProps) {
const message = formData.get("message") as string; const message = formData.get("message") as string;
if (name && email && message) { if (name && email && message) {
// Get fresh Turnstile token
let currentToken = turnstileToken(); let currentToken = turnstileToken();
if ( if (
!currentToken && !currentToken &&
@@ -388,7 +385,6 @@ export function ContactForm(props: ContactFormProps) {
setError(""); setError("");
form.reset(); form.reset();
// Reset Turnstile widget
if (typeof window !== "undefined" && (window as any).turnstile) { if (typeof window !== "undefined" && (window as any).turnstile) {
const widgetEl = document.getElementById("turnstile-widget-1"); const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) { if (widgetEl) {

View File

@@ -138,9 +138,6 @@ export function Typewriter(props: {
entries.forEach((entry) => { entries.forEach((entry) => {
// If component leaves viewport while animating, we could pause // If component leaves viewport while animating, we could pause
// For now, we just ensure it starts when visible // 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); reader.readAsDataURL(file);
// Refresh the S3 file list
await loadAttachments(); await loadAttachments();
} }
} catch (err) { } catch (err) {
@@ -81,7 +80,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
body: JSON.stringify({ key }) body: JSON.stringify({ key })
}); });
// Refresh the S3 file list
await loadAttachments(); await loadAttachments();
} catch (err) { } catch (err) {
console.error("Failed to delete file:", 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) => { const newComment = async (commentBody: string, parentCommentID?: number) => {
setCommentSubmitLoading(true); setCommentSubmitLoading(true);
// Clear any existing timeout
if (commentSubmitTimeoutId) { if (commentSubmitTimeoutId) {
clearTimeout(commentSubmitTimeoutId); clearTimeout(commentSubmitTimeoutId);
} }
@@ -428,7 +427,6 @@ export default function CommentSectionWrapper(
const editComment = async (body: string, comment_id: number) => { const editComment = async (body: string, comment_id: number) => {
setCommentEditLoading(true); setCommentEditLoading(true);
// Clear any existing timeout
if (editCommentTimeoutId) { if (editCommentTimeoutId) {
clearTimeout(editCommentTimeoutId); clearTimeout(editCommentTimeoutId);
} }
@@ -527,7 +525,6 @@ export default function CommentSectionWrapper(
setCommentDeletionLoading(true); setCommentDeletionLoading(true);
// Clear any existing timeout
if (deleteCommentTimeoutId) { if (deleteCommentTimeoutId) {
clearTimeout(deleteCommentTimeoutId); clearTimeout(deleteCommentTimeoutId);
} }
@@ -623,7 +620,6 @@ export default function CommentSectionWrapper(
setOperationError(""); setOperationError("");
if (data.commentBody) { if (data.commentBody) {
// Soft delete (replace body with deletion message)
setAllComments((prev) => setAllComments((prev) =>
prev.map((comment) => { prev.map((comment) => {
if (comment.id === data.commentID) { if (comment.id === data.commentID) {
@@ -652,7 +648,6 @@ export default function CommentSectionWrapper(
}) })
); );
} else { } else {
// Hard delete (remove from list)
setAllComments((prev) => setAllComments((prev) =>
prev.filter((comment) => comment.id !== data.commentID) prev.filter((comment) => comment.id !== data.commentID)
); );
@@ -667,7 +662,6 @@ export default function CommentSectionWrapper(
}, 300); }, 300);
}; };
// Deletion/edit prompt toggle
const toggleModification = ( const toggleModification = (
commentID: number, commentID: number,
commenterID: string, commenterID: string,
@@ -708,7 +702,6 @@ export default function CommentSectionWrapper(
setCommentBodyForModification(""); setCommentBodyForModification("");
}; };
// Reaction handling
const commentReaction = (reactionType: ReactionType, commentID: number) => { const commentReaction = (reactionType: ReactionType, commentID: number) => {
if (!props.currentUserID) { if (!props.currentUserID) {
console.warn("Cannot react to comment: user not authenticated"); console.warn("Cannot react to comment: user not authenticated");
@@ -800,7 +793,6 @@ export default function CommentSectionWrapper(
} }
}; };
// Click outside handlers (SolidJS version)
createEffect(() => { createEffect(() => {
const handleClickOutsideDelete = (e: MouseEvent) => { const handleClickOutsideDelete = (e: MouseEvent) => {
if ( if (

View File

@@ -8,12 +8,10 @@ function sanitizeMermaidSvg(svgString: string): string {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(svgString, "text/html"); const doc = parser.parseFromString(svgString, "text/html");
// Remove dangerous elements
doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => { doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => {
el.remove(); el.remove();
}); });
// Remove event handlers and dangerous attributes from all elements
doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => { doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => {
const attrs = Array.from(el.attributes); const attrs = Array.from(el.attributes);
attrs.forEach((attr) => { attrs.forEach((attr) => {

View File

@@ -11,7 +11,6 @@ function sanitizeHtml(html: string): string {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html"); const doc = parser.parseFromString(html, "text/html");
// Remove dangerous elements
doc doc
.querySelectorAll( .querySelectorAll(
"script, iframe, object, embed, form, link, meta, base, svg script" "script, iframe, object, embed, form, link, meta, base, svg script"
@@ -131,7 +130,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const processVideos = () => { const processVideos = () => {
if (!contentRef) return; if (!contentRef) return;
// Handle direct video elements
const videoElements = contentRef.querySelectorAll("video"); const videoElements = contentRef.querySelectorAll("video");
videoElements.forEach((video) => { videoElements.forEach((video) => {
@@ -139,18 +137,14 @@ export default function PostBodyClient(props: PostBodyClientProps) {
video.setAttribute("playsinline", ""); video.setAttribute("playsinline", "");
video.setAttribute("controls", ""); video.setAttribute("controls", "");
// Remove download attribute if present
video.removeAttribute("download"); video.removeAttribute("download");
// Ensure proper MIME types on source elements
const sources = video.querySelectorAll("source"); const sources = video.querySelectorAll("source");
sources.forEach((source) => { sources.forEach((source) => {
const src = source.getAttribute("src"); const src = source.getAttribute("src");
if (src) { if (src) {
// Remove download attribute from sources
source.removeAttribute("download"); source.removeAttribute("download");
// Set correct type attribute if missing
if (!source.hasAttribute("type")) { if (!source.hasAttribute("type")) {
if (src.endsWith(".mp4")) { if (src.endsWith(".mp4")) {
source.setAttribute("type", "video/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"); const videoSrc = video.getAttribute("src");
if (videoSrc && !video.hasAttribute("type")) { if (videoSrc && !video.hasAttribute("type")) {
if (videoSrc.endsWith(".mp4")) { 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"); const iframes = contentRef.querySelectorAll("iframe");
iframes.forEach((iframe) => { iframes.forEach((iframe) => {
const src = iframe.getAttribute("src"); const src = iframe.getAttribute("src");
@@ -187,7 +179,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
src.endsWith(".webm") || src.endsWith(".webm") ||
src.endsWith(".ogg")) src.endsWith(".ogg"))
) { ) {
// Create a proper video element
const video = document.createElement("video"); const video = document.createElement("video");
video.setAttribute("controls", ""); video.setAttribute("controls", "");
video.setAttribute("playsinline", ""); video.setAttribute("playsinline", "");
@@ -195,7 +186,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
video.style.maxWidth = "100%"; video.style.maxWidth = "100%";
video.style.height = "auto"; video.style.height = "auto";
// Set appropriate type based on file extension
let videoType = "video/mp4"; let videoType = "video/mp4";
if (src.endsWith(".mov")) { if (src.endsWith(".mov")) {
videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4 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.setAttribute("type", videoType);
video.src = src; video.src = src;
// Replace the iframe with the video element
const parent = iframe.parentElement; const parent = iframe.parentElement;
if (parent) { if (parent) {
parent.replaceChild(video, iframe); 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"); const videoLinks = contentRef.querySelectorAll("a");
videoLinks.forEach((link) => { videoLinks.forEach((link) => {
const hasVideo = link.querySelector("video"); const hasVideo = link.querySelector("video");

View File

@@ -58,7 +58,6 @@ export default function PostForm(props: PostFormProps) {
props.postId 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 // Use setTimeout to ensure this runs after all signals are initialized
createEffect(() => { createEffect(() => {
if (props.mode === "edit" && props.initialData) { if (props.mode === "edit" && props.initialData) {
@@ -73,12 +72,10 @@ export default function PostForm(props: PostFormProps) {
}, 5000); }, 5000);
}; };
// Helper to ensure post exists (create if needed)
const ensurePostExists = async (): Promise<number> => { const ensurePostExists = async (): Promise<number> => {
const existingId = createdPostId() || props.postId; const existingId = createdPostId() || props.postId;
if (existingId) return existingId; if (existingId) return existingId;
// Create minimal post if it doesn't exist yet
const result = await api.database.createPost.mutate({ const result = await api.database.createPost.mutate({
category: "blog", category: "blog",
title: title().replaceAll(" ", "_") || "Untitled", title: title().replaceAll(" ", "_") || "Untitled",
@@ -95,7 +92,6 @@ export default function PostForm(props: PostFormProps) {
return newId; return newId;
}; };
// Individual autosave functions for each field
const autoSaveTitle = async () => { const autoSaveTitle = async () => {
const currentTitle = title(); const currentTitle = title();
if (!currentTitle || currentTitle === props.initialData?.title) return; 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 debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500);
const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500); const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500);
const debouncedAutoSaveBody = debounce(autoSaveBody, 2500); const debouncedAutoSaveBody = debounce(autoSaveBody, 2500);
@@ -256,7 +251,6 @@ export default function PostForm(props: PostFormProps) {
const debouncedAutoSavePublished = debounce(autoSavePublished, 1000); const debouncedAutoSavePublished = debounce(autoSavePublished, 1000);
const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500); const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500);
// Individual effects for each field
createEffect(() => { createEffect(() => {
const titleVal = title(); const titleVal = title();
if (isInitialLoad()) return; if (isInitialLoad()) return;
@@ -405,7 +399,6 @@ export default function PostForm(props: PostFormProps) {
author_id: props.userID author_id: props.userID
}); });
} else { } else {
// Create new post
const result = await api.database.createPost.mutate({ const result = await api.database.createPost.mutate({
category: "blog", category: "blog",
title: title().replaceAll(" ", "_"), title: title().replaceAll(" ", "_"),

View File

@@ -100,7 +100,7 @@ export default function PostSorting(props: PostSortingProps) {
case "newest": case "newest":
break; // Posts already come newest first from DB (DESC order) break; // Posts already come newest first from DB (DESC order)
case "oldest": case "oldest":
sorted.reverse(); // Reverse to get oldest first sorted.reverse();
break; break;
case "most_liked": case "most_liked":
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0)); 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: { handleDOMEvents: {
touchstart: (view, event) => { touchstart: (view, event) => {
// Only handle touch events on mobile in fullscreen with active suggestion
if ( if (
!hasSuggestion() || !hasSuggestion() ||
!isFullscreen() || !isFullscreen() ||
@@ -1562,7 +1561,6 @@ export default function TextEditor(props: TextEditorProps) {
return false; return false;
}, },
touchend: (view, event) => { touchend: (view, event) => {
// Only handle touch events on mobile in fullscreen with active suggestion
if ( if (
!hasSuggestion() || !hasSuggestion() ||
!isFullscreen() || !isFullscreen() ||
@@ -1860,7 +1858,6 @@ export default function TextEditor(props: TextEditorProps) {
const node = allSuperscriptNodes[i]; const node = allSuperscriptNodes[i];
const text = node.text; const text = node.text;
// Check if this is a complete reference (with optional whitespace)
const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/); const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/);
if (completeMatch) { if (completeMatch) {
const hasOtherMarks = node.marks.some( const hasOtherMarks = node.marks.some(
@@ -1877,7 +1874,6 @@ export default function TextEditor(props: TextEditorProps) {
continue; continue;
} }
// Check if this might be the start of a split reference
if (text === "[" && i + 2 < allSuperscriptNodes.length) { if (text === "[" && i + 2 < allSuperscriptNodes.length) {
const nextNode = allSuperscriptNodes[i + 1]; const nextNode = allSuperscriptNodes[i + 1];
const afterNode = allSuperscriptNodes[i + 2]; const afterNode = allSuperscriptNodes[i + 2];
@@ -1958,7 +1954,6 @@ export default function TextEditor(props: TextEditorProps) {
allRefs.sort((a, b) => a.pos - b.pos); 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; let needsRenumbering = false;
for (let i = 0; i < allRefs.length; i++) { for (let i = 0; i < allRefs.length; i++) {
if (allRefs[i].refNum !== i + 1) { if (allRefs[i].refNum !== i + 1) {

View File

@@ -69,7 +69,6 @@ export const Mermaid = Node.create({
getAttrs: (element) => { getAttrs: (element) => {
if (typeof element === "string") return false; if (typeof element === "string") return false;
// Skip if already has data-type or data-mermaid-diagram attribute
if ( if (
element.hasAttribute("data-type") || element.hasAttribute("data-type") ||
element.hasAttribute("data-mermaid-diagram") element.hasAttribute("data-mermaid-diagram")
@@ -83,7 +82,6 @@ export const Mermaid = Node.create({
const content = code.textContent || ""; const content = code.textContent || "";
const trimmedContent = content.trim(); const trimmedContent = content.trim();
// Check if this looks like a mermaid diagram
const mermaidKeywords = [ const mermaidKeywords = [
"graph ", "graph ",
"sequenceDiagram", "sequenceDiagram",
@@ -174,12 +172,10 @@ export const Mermaid = Node.create({
code.textContent = node.attrs.content || ""; code.textContent = node.attrs.content || "";
pre.appendChild(code); pre.appendChild(code);
// Validation status indicator
const statusIndicator = document.createElement("div"); const statusIndicator = document.createElement("div");
statusIndicator.className = statusIndicator.className =
"absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200"; "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 validateSyntax = async () => {
const content = node.attrs.content || ""; const content = node.attrs.content || "";
if (!content.trim()) { if (!content.trim()) {
@@ -250,7 +246,6 @@ export const Mermaid = Node.create({
(p: any) => p.spec?.key === "mermaidSelection" (p: any) => p.spec?.key === "mermaidSelection"
); );
// Use intersection observer to trigger update when visible
let updateInterval: ReturnType<typeof setInterval> | null = null; let updateInterval: ReturnType<typeof setInterval> | null = null;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (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; height: number;
} | null>(null); } | null>(null);
// Measure content dimensions when not loading
createEffect(() => { createEffect(() => {
if (!local.loading && contentRef) { if (!local.loading && contentRef) {
const rect = contentRef.getBoundingClientRect(); 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_DEV: "2m" as const, // 2 minutes for faster testing
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
// Other Auth Settings
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const, EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
EMAIL_VERIFICATION_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 } EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
} as const; } as const;
/** Rate limit store cleanup interval (5 minutes) */
export const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
// ============================================================ // ============================================================
// ACCOUNT SECURITY // ACCOUNT SECURITY
// ============================================================ // ============================================================
@@ -136,22 +132,6 @@ export const NETWORK_CONFIG = {
RETRY_DELAY_MS: 1000 RETRY_DELAY_MS: 1000
} as const; } 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 // UI/UX - COUNTDOWN TIMER COMPONENT
// ============================================================ // ============================================================
@@ -177,41 +157,6 @@ export const BREAKPOINTS = {
DESKTOP_MIN_WIDTH: 1025 DESKTOP_MIN_WIDTH: 1025
} as const; } 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 // UI/UX - MOBILE CONFIG
// ============================================================ // ============================================================
@@ -284,22 +229,4 @@ export const LINEAGE_CONFIG = {
JWT_AUDIENCE: "lineage-app" as const JWT_AUDIENCE: "lineage-app" as const
} 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. */ /** 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; if (typeof window === "undefined") return MAIN_SITE;
const injected = window.__SITE__; const injected = window.__SITE__;
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected]; 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() // Get server state using createAsync which works with cache()
const serverAuth = createAsync(() => getUserState(), { deferStream: true }); const serverAuth = createAsync(() => getUserState(), { deferStream: true });
// Refresh callback that forces re-fetch
const refreshAuth = () => { const refreshAuth = () => {
// Manually trigger a re-fetch by calling the revalidate function
revalidate(["user-auth-state"]); revalidate(["user-auth-state"]);
}; };
@@ -70,7 +68,6 @@ export const AuthProvider: ParentComponent = (props) => {
// Server handles all token refresh logic // Server handles all token refresh logic
// Client just displays the current auth state from server // Client just displays the current auth state from server
// Listen for auth refresh events from external sources (token refresh, etc.)
onMount(() => { onMount(() => {
if (typeof window === "undefined") return; 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 10
); );
// Reset counter if outside the time window
if (now - lastReloadTime > RELOAD_WINDOW_MS) { if (now - lastReloadTime > RELOAD_WINDOW_MS) {
sessionStorage.setItem(RELOAD_STORAGE_KEY, "0"); sessionStorage.setItem(RELOAD_STORAGE_KEY, "0");
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString()); sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
return true; return true;
} }
// Check if we've exceeded max reloads
if (reloadCount >= MAX_RELOADS) { if (reloadCount >= MAX_RELOADS) {
console.error( console.error(
`Exceeded ${MAX_RELOADS} reload attempts in ${RELOAD_WINDOW_MS}ms. Stopping to prevent infinite loop.` `Exceeded ${MAX_RELOADS} reload attempts in ${RELOAD_WINDOW_MS}ms. Stopping to prevent infinite loop.`
@@ -49,12 +47,10 @@ function shouldAttemptReload(): boolean {
return false; return false;
} }
// Increment counter and allow reload
sessionStorage.setItem(RELOAD_STORAGE_KEY, (reloadCount + 1).toString()); sessionStorage.setItem(RELOAD_STORAGE_KEY, (reloadCount + 1).toString());
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString()); sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
return true; return true;
} catch (e) { } catch (e) {
// If sessionStorage fails, allow reload but log error
console.warn("Failed to access sessionStorage:", e); console.warn("Failed to access sessionStorage:", e);
return true; return true;
} }
@@ -102,7 +98,6 @@ function handleChunkError(source: string): void {
} }
} }
// Handle runtime chunk loading errors
window.addEventListener("error", (event) => { window.addEventListener("error", (event) => {
if ( if (
event.message?.includes("Importing a module script failed") || 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) => { window.addEventListener("unhandledrejection", (event) => {
if ( if (
event.reason?.message?.includes("Importing a module script failed") || 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", () => { window.addEventListener("load", () => {
// Only clear if we successfully loaded (we're past the critical chunk loading phase)
setTimeout(() => { setTimeout(() => {
sessionStorage.removeItem(RELOAD_STORAGE_KEY); sessionStorage.removeItem(RELOAD_STORAGE_KEY);
sessionStorage.removeItem(RELOAD_TIMESTAMP_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; return envVars as unknown as ClientEnv;
}; };
const validateAndExportEnv = (): ClientEnv => { export const env = validateClientEnv(import.meta.env);
try {
const validated = validateClientEnv(import.meta.env);
return validated;
} catch (error) {
throw error;
}
};
export const env = validateAndExportEnv();
export const isMissingEnvVar = (varName: string): boolean => { export const isMissingEnvVar = (varName: string): boolean => {
return !import.meta.env[varName] || import.meta.env[varName]?.trim() === ""; 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 * Call this after login, logout, token refresh, email verification
*/ */
export function revalidateAuth() { export function revalidateAuth() {
// Revalidate the cache
revalidateKey("user-auth-state"); revalidateKey("user-auth-state");
// Dispatch event to trigger UI updates (client-side only) // Dispatch event to trigger UI updates (client-side only)

View File

@@ -3,21 +3,6 @@
* Note: These utilities should only run in the browser * 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) * Decode JWT payload without verification (client-side only)
* @param token - JWT token string * @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`; return `${diffDay}d ago`;
} }
} else { } else {
// style === "long"
if (includeSeconds && diffSec < 60) { if (includeSeconds && diffSec < 60) {
return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`; return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
} }

View File

@@ -12,14 +12,12 @@ const VERSION_STORAGE_KEY = "app-version-hash";
*/ */
function getCurrentVersionHash(): string { function getCurrentVersionHash(): string {
try { try {
// Use a combination of script tags to detect version
const scripts = Array.from(document.querySelectorAll("script[src]")) const scripts = Array.from(document.querySelectorAll("script[src]"))
.map((s) => (s as HTMLScriptElement).src) .map((s) => (s as HTMLScriptElement).src)
.filter((src) => src.includes("/_build/")) .filter((src) => src.includes("/_build/"))
.sort() .sort()
.join(","); .join(",");
// Simple hash function
let hash = 0; let hash = 0;
for (let i = 0; i < scripts.length; i++) { for (let i = 0; i < scripts.length; i++) {
const char = scripts.charCodeAt(i); const char = scripts.charCodeAt(i);
@@ -39,7 +37,6 @@ function getCurrentVersionHash(): string {
*/ */
async function checkForNewVersion(): Promise<boolean> { async function checkForNewVersion(): Promise<boolean> {
try { try {
// Fetch current page HTML
const response = await fetch(window.location.pathname, { const response = await fetch(window.location.pathname, {
method: "HEAD", method: "HEAD",
cache: "no-cache" cache: "no-cache"
@@ -64,7 +61,6 @@ async function checkForNewVersion(): Promise<boolean> {
return true; return true;
} }
// Store current ETag for future checks
if (newEtag) { if (newEtag) {
sessionStorage.setItem("app-etag", newEtag); sessionStorage.setItem("app-etag", newEtag);
} }
@@ -80,7 +76,6 @@ async function checkForNewVersion(): Promise<boolean> {
* Show update notification to user * Show update notification to user
*/ */
function showUpdateNotification(): void { function showUpdateNotification(): void {
// Only show once per session
if (sessionStorage.getItem("update-notification-shown")) { if (sessionStorage.getItem("update-notification-shown")) {
return; return;
} }
@@ -147,7 +142,6 @@ function showUpdateNotification(): void {
document.body.appendChild(notification); document.body.appendChild(notification);
// Auto-remove after 30 seconds
setTimeout(() => { setTimeout(() => {
if (notification.parentElement) { if (notification.parentElement) {
notification.style.animation = "slideIn 0.3s ease-out reverse"; notification.style.animation = "slideIn 0.3s ease-out reverse";
@@ -162,11 +156,9 @@ function showUpdateNotification(): void {
export function startDeploymentMonitoring(): void { export function startDeploymentMonitoring(): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// Store initial version
const initialVersion = getCurrentVersionHash(); const initialVersion = getCurrentVersionHash();
sessionStorage.setItem(VERSION_STORAGE_KEY, initialVersion); sessionStorage.setItem(VERSION_STORAGE_KEY, initialVersion);
// Periodic version check
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
const hasNewVersion = await checkForNewVersion(); const hasNewVersion = await checkForNewVersion();
if (hasNewVersion) { if (hasNewVersion) {
@@ -174,7 +166,6 @@ export function startDeploymentMonitoring(): void {
} }
}, VERSION_CHECK_INTERVAL); }, VERSION_CHECK_INTERVAL);
// Check on visibility change (user returns to tab)
const handleVisibilityChange = async () => { const handleVisibilityChange = async () => {
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
const hasNewVersion = await checkForNewVersion(); const hasNewVersion = await checkForNewVersion();
@@ -186,7 +177,6 @@ export function startDeploymentMonitoring(): void {
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
// Cleanup function
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
(window as any).__cleanupDeploymentMonitoring = () => { (window as any).__cleanupDeploymentMonitoring = () => {
clearInterval(intervalId); clearInterval(intervalId);

View File

@@ -26,7 +26,6 @@ export function initPerformanceTracking() {
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []); const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
// Observe LCP
if (supported.has("largest-contentful-paint")) { if (supported.has("largest-contentful-paint")) {
try { try {
const lcpObserver = new PerformanceObserver((entryList) => { const lcpObserver = new PerformanceObserver((entryList) => {
@@ -40,7 +39,6 @@ export function initPerformanceTracking() {
} }
} }
// Observe CLS
if (supported.has("layout-shift")) { if (supported.has("layout-shift")) {
try { try {
const clsObserver = new PerformanceObserver((entryList) => { const clsObserver = new PerformanceObserver((entryList) => {
@@ -59,7 +57,6 @@ export function initPerformanceTracking() {
} }
} }
// Observe FID
if (supported.has("first-input")) { if (supported.has("first-input")) {
try { try {
const fidObserver = new PerformanceObserver((entryList) => { const fidObserver = new PerformanceObserver((entryList) => {
@@ -74,7 +71,6 @@ export function initPerformanceTracking() {
} }
} }
// Observe INP (event timing)
if (supported.has("event")) { if (supported.has("event")) {
try { try {
const interactions: number[] = []; const interactions: number[] = [];
@@ -96,7 +92,6 @@ export function initPerformanceTracking() {
} }
} }
// Get navigation timing metrics
window.addEventListener("load", () => { window.addEventListener("load", () => {
setTimeout(() => { setTimeout(() => {
const navTiming = performance.getEntriesByType( const navTiming = performance.getEntriesByType(
@@ -110,7 +105,6 @@ export function initPerformanceTracking() {
metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart; metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart;
} }
// Get FCP
const paintEntries = performance.getEntriesByType("paint"); const paintEntries = performance.getEntriesByType("paint");
const fcpEntry = paintEntries.find( const fcpEntry = paintEntries.find(
(entry) => entry.name === "first-contentful-paint" (entry) => entry.name === "first-contentful-paint"
@@ -135,7 +129,6 @@ export function initPerformanceTracking() {
} }
function sendMetrics() { function sendMetrics() {
// Only send if we have at least one metric
if (Object.keys(metrics).length === 0) { if (Object.keys(metrics).length === 0) {
return; return;
} }
@@ -157,7 +150,6 @@ function sendMetrics() {
const blob = new Blob([payload], { type: "application/json" }); const blob = new Blob([payload], { type: "application/json" });
navigator.sendBeacon(apiUrl, blob); navigator.sendBeacon(apiUrl, blob);
} else { } else {
// Fallback to fetch with keepalive
fetch(apiUrl, { fetch(apiUrl, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -168,6 +160,5 @@ function sendMetrics() {
); );
} }
// Clear metrics after sending
metrics = {}; metrics = {};
} }

View File

@@ -61,7 +61,6 @@ export default async function AddImageToS3(
throw new Error("Failed to upload file to S3"); throw new Error("Failed to upload file to S3");
} }
// Create thumbnails for images (blog posts only)
if (type === "blog" && isImage) { if (type === "blog" && isImage) {
try { try {
const thumbnail = await resizeImage(file, 200, 200, 0.8); 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. * 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}`; const loc = `https://${site.domain}${entry.path}`;
return ` <url> return ` <url>
<loc>${xmlEscape(loc)}</loc> <loc>${xmlEscape(loc)}</loc>

View File

@@ -28,13 +28,11 @@ describe("generateSitemap", () => {
it("generates valid XML for main site with all expected routes", () => { it("generates valid XML for main site with all expected routes", () => {
const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main); const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main);
// Basic structure
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>'); expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain( expect(xml).toContain(
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
); );
// All main site paths present with freno.me domain
const locs = extractLocs(xml); const locs = extractLocs(xml);
expect(locs).toContain("https://freno.me/"); expect(locs).toContain("https://freno.me/");
expect(locs).toContain("https://freno.me/blog"); 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/resume");
expect(locs).toContain("https://freno.me/downloads"); expect(locs).toContain("https://freno.me/downloads");
// Exactly 6 entries
expect(locs.length).toBe(6); expect(locs.length).toBe(6);
// Verify well-formedness by checking balanced tags
expect(xml).toContain("</urlset>"); expect(xml).toContain("</urlset>");
const urlOpens = (xml.match(/<url>/g) || []).length; const urlOpens = (xml.match(/<url>/g) || []).length;
const urlCloses = (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", () => { it("generates valid parseable XML for lineage site", () => {
const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage); const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage);
// Verify balanced tags
expect(xml).toContain("</urlset>"); expect(xml).toContain("</urlset>");
const urlOpens = (xml.match(/<url>/g) || []).length; const urlOpens = (xml.match(/<url>/g) || []).length;
const urlCloses = (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).toContain("https://nessa.freno.me/privacy");
expect(locs.length).toBe(3); expect(locs.length).toBe(3);
// No leakage from main site
for (const loc of locs) { for (const loc of locs) {
expect(loc).not.toContain("://freno.me/"); expect(loc).not.toContain("://freno.me/");
expect(loc).not.toContain("://freno.me/blog"); expect(loc).not.toContain("://freno.me/blog");

View File

@@ -45,15 +45,12 @@ export function useCountdown(options: UseCountdownOptions = {}) {
}; };
const startCountdown = (expiresAt: string | Date) => { const startCountdown = (expiresAt: string | Date) => {
// Clear any existing interval
if (intervalId !== null) { if (intervalId !== null) {
clearInterval(intervalId); clearInterval(intervalId);
} }
// Calculate immediately
calculateRemaining(expiresAt); calculateRemaining(expiresAt);
// Then update every second
intervalId = setInterval(() => calculateRemaining(expiresAt), 1000); intervalId = setInterval(() => calculateRemaining(expiresAt), 1000);
}; };
@@ -64,7 +61,6 @@ export function useCountdown(options: UseCountdownOptions = {}) {
} }
}; };
// Cleanup on unmount
onCleanup(() => { onCleanup(() => {
stopCountdown(); stopCountdown();
}); });

View File

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

View File

@@ -20,7 +20,6 @@ export async function GET(event: APIEvent) {
"emailLogin", "emailLogin",
(caller, params) => caller.auth.emailLogin(params), (caller, params) => caller.auth.emailLogin(params),
(error) => { (error) => {
// Check for token expiration
const message = error instanceof Error ? error.message : ""; const message = error instanceof Error ? error.message : "";
const isTokenError = const isTokenError =
message.includes("expired") || message.includes("invalid"); message.includes("expired") || message.includes("invalid");

View File

@@ -62,17 +62,14 @@ export async function GET(event: APIEvent) {
} }
try { try {
// Create tRPC caller to invoke the emailVerification procedure
const caller = await createServerCaller(event); const caller = await createServerCaller(event);
// Call the email verification handler
const result = await caller.auth.emailVerification({ const result = await caller.auth.emailVerification({
email, email,
token token
}); });
if (result.success) { if (result.success) {
// Show success page
return new Response( return new Response(
` `
<!DOCTYPE html> <!DOCTYPE html>
@@ -136,7 +133,6 @@ export async function GET(event: APIEvent) {
} catch (error) { } catch (error) {
console.error("Email verification callback error:", error); console.error("Email verification callback error:", error);
// Check if it's a token expiration error
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "server_error"; error instanceof Error ? error.message : "server_error";
const isTokenError = 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 validPrefixes = ["Gaze", "InputHalo"];
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix)); const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
if ( if (
@@ -70,12 +69,10 @@ export async function GET(event: APIEvent) {
}); });
} }
// Get content type based on file extension
const contentType = filename.endsWith(".dmg") const contentType = filename.endsWith(".dmg")
? "application/x-apple-diskimage" ? "application/x-apple-diskimage"
: "application/octet-stream"; : "application/octet-stream";
// Stream the file content from S3
const body = await response.Body.transformToByteArray(); const body = await response.Body.transformToByteArray();
console.log(`✓ Serving ${filename} (${body.length} bytes)`); console.log(`✓ Serving ${filename} (${body.length} bytes)`);
@@ -93,7 +90,6 @@ export async function GET(event: APIEvent) {
} catch (error) { } catch (error) {
console.error(`Failed to fetch ${filename} from S3:`, 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") { if (error instanceof Error && error.name === "NoSuchKey") {
return new Response("File not found in storage", { return new Response("File not found in storage", {
status: 404, status: 404,

View File

@@ -58,8 +58,3 @@ export function bearerToken(event: APIEvent): string | null {
const m = auth.match(/^Bearer\s+(.+)$/i); const m = auth.match(/^Bearer\s+(.+)$/i);
return m?.[1]?.trim() ?? null; 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 [gazeText, setGazeText] = createSignal("Gaze");
const [inputHaloText, setInputHaloText] = createSignal("InputHalo"); const [inputHaloText, setInputHaloText] = createSignal("InputHalo");
// Track loading states for each download button
const [loadingState, setLoadingState] = createSignal<Record<string, boolean>>( const [loadingState, setLoadingState] = createSignal<Record<string, boolean>>(
{ {
lineage: false, lineage: false,
@@ -32,10 +31,8 @@ function MainDownloadsPage() {
// Prevent multiple rapid clicks // Prevent multiple rapid clicks
if (loadingState()[assetName]) return; if (loadingState()[assetName]) return;
// Set loading state
setLoadingState((prev) => ({ ...prev, [assetName]: true })); setLoadingState((prev) => ({ ...prev, [assetName]: true }));
// Call the tRPC endpoint directly
import("~/lib/api").then(({ api }) => { import("~/lib/api").then(({ api }) => {
api.downloads.getDownloadUrl api.downloads.getDownloadUrl
.query({ asset_name: assetName }) .query({ asset_name: assetName })
@@ -45,11 +42,9 @@ function MainDownloadsPage() {
}) })
.catch((error) => { .catch((error) => {
console.error("Download error:", error); console.error("Download error:", error);
// Optionally show user a message
alert("Failed to initiate download. Please try again."); alert("Failed to initiate download. Please try again.");
}) })
.finally(() => { .finally(() => {
// Reset loading state regardless of success/failure
setLoadingState((prev) => ({ ...prev, [assetName]: false })); setLoadingState((prev) => ({ ...prev, [assetName]: false }));
}); });
}); });

View File

@@ -57,7 +57,6 @@ export const route = {
load: () => checkAuth() load: () => checkAuth()
}; };
// Helper to convert expiry string to human-readable format
function expiryToHuman(expiry: string): string { function expiryToHuman(expiry: string): string {
const value = parseInt(expiry); const value = parseInt(expiry);
if (expiry.endsWith("m")) { if (expiry.endsWith("m")) {
@@ -77,7 +76,6 @@ export default function LoginPage() {
const register = () => searchParams.mode === "register"; const register = () => searchParams.mode === "register";
const usePassword = () => searchParams.auth === "password"; const usePassword = () => searchParams.auth === "password";
// Load server data using createAsync
const loginData = createAsync(() => getLoginData(), { const loginData = createAsync(() => getLoginData(), {
deferStream: true deferStream: true
}); });
@@ -149,18 +147,12 @@ export default function LoginPage() {
} }
}); });
const formHandler = async (e: Event) => { const isRateLimited = (errorCode: string | undefined, message: string) =>
e.preventDefault(); errorCode === "TOO_MANY_REQUESTS" || message.includes("Too many attempts");
setLoading(true);
setError("");
setShowPasswordError(false);
setShowPasswordSuccess(false);
try { const submitRegister = async () => {
if (register()) {
if (!emailRef || !passwordRef || !passwordConfRef) { if (!emailRef || !passwordRef || !passwordConfRef) {
setError("Please fill in all fields"); setError("Please fill in all fields");
setLoading(false);
return; return;
} }
@@ -170,20 +162,17 @@ export default function LoginPage() {
if (!isValidEmail(email)) { if (!isValidEmail(email)) {
setError("Invalid email address"); setError("Invalid email address");
setLoading(false);
return; return;
} }
const passwordValidation = validatePassword(password); const passwordValidation = validatePassword(password);
if (!passwordValidation.isValid) { if (!passwordValidation.isValid) {
setError(passwordValidation.errors[0] || "Invalid password"); setError(passwordValidation.errors[0] || "Invalid password");
setLoading(false);
return; return;
} }
if (password !== passwordConf) { if (password !== passwordConf) {
setError("passwordMismatch"); setError("passwordMismatch");
setLoading(false);
return; return;
} }
@@ -201,17 +190,16 @@ export default function LoginPage() {
if (response.ok && result.result?.data) { if (response.ok && result.result?.data) {
navigate("/account", { replace: true }); navigate("/account", { replace: true });
} else { return;
}
const errorMsg = const errorMsg =
result.error?.message || result.error?.message ||
result.result?.data?.message || result.result?.data?.message ||
"Registration failed"; "Registration failed";
const errorCode = result.error?.data?.code; const errorCode = result.error?.data?.code;
if ( if (isRateLimited(errorCode, errorMsg)) {
errorCode === "TOO_MANY_REQUESTS" ||
errorMsg.includes("Too many attempts")
) {
setError(errorMsg); setError(errorMsg);
} else if ( } else if (
errorMsg.includes("duplicate") || errorMsg.includes("duplicate") ||
@@ -225,11 +213,11 @@ export default function LoginPage() {
} else { } else {
setError(errorMsg); setError(errorMsg);
} }
} };
} else if (usePassword()) {
const submitPasswordLogin = async () => {
if (!emailRef || !passwordRef || !rememberMeRef) { if (!emailRef || !passwordRef || !rememberMeRef) {
setError("Please fill in all fields"); setError("Please fill in all fields");
setLoading(false);
return; return;
} }
@@ -251,14 +239,13 @@ export default function LoginPage() {
setTimeout(() => { setTimeout(() => {
navigate("/account", { replace: true }); navigate("/account", { replace: true });
}, 500); }, 500);
} else { return;
}
const errorMessage = result.error?.message || ""; const errorMessage = result.error?.message || "";
const errorCode = result.error?.data?.code; const errorCode = result.error?.data?.code;
if ( if (isRateLimited(errorCode, errorMessage)) {
errorCode === "TOO_MANY_REQUESTS" ||
errorMessage.includes("Too many attempts")
) {
setError(errorMessage); setError(errorMessage);
} else if ( } else if (
errorCode === "FORBIDDEN" || errorCode === "FORBIDDEN" ||
@@ -269,11 +256,11 @@ export default function LoginPage() {
} else { } else {
setShowPasswordError(true); setShowPasswordError(true);
} }
} };
} else {
const submitEmailLink = async () => {
if (!emailRef || !rememberMeRef) { if (!emailRef || !rememberMeRef) {
setError("Please enter your email"); setError("Please enter your email");
setLoading(false);
return; return;
} }
@@ -282,7 +269,6 @@ export default function LoginPage() {
if (!isValidEmail(email)) { if (!isValidEmail(email)) {
setError("Invalid email address"); setError("Invalid email address");
setLoading(false);
return; return;
} }
@@ -302,7 +288,9 @@ export default function LoginPage() {
Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS
); );
startCountdown(expirationTime); startCountdown(expirationTime);
} else { return;
}
const errorMsg = const errorMsg =
result.error?.message || result.error?.message ||
result.result?.data?.message || result.result?.data?.message ||
@@ -310,9 +298,8 @@ export default function LoginPage() {
const errorCode = result.error?.data?.code; const errorCode = result.error?.data?.code;
if ( if (
errorCode === "TOO_MANY_REQUESTS" || isRateLimited(errorCode, errorMsg) ||
errorMsg.includes("countdown not expired") || errorMsg.includes("countdown not expired")
errorMsg.includes("Too many attempts")
) { ) {
setError( setError(
errorMsg.includes("countdown") errorMsg.includes("countdown")
@@ -332,7 +319,22 @@ export default function LoginPage() {
} else { } else {
setError(errorMsg); 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) { } catch (err: any) {
console.error("Login error:", err); console.error("Login error:", err);

View File

@@ -863,7 +863,6 @@ export default function TestPage() {
setErrors({ ...errors(), [key]: "" }); setErrors({ ...errors(), [key]: "" });
try { try {
// Get input - either from edited JSON or sample
let input = endpoint.sampleInput; let input = endpoint.sampleInput;
const editedInput = inputEdits()[key]; const editedInput = inputEdits()[key];
if (editedInput) { if (editedInput) {

View File

@@ -117,14 +117,11 @@ function scheduleAnalyticsFlush(): void {
*/ */
export async function logVisit(entry: AnalyticsEntry): Promise<void> { export async function logVisit(entry: AnalyticsEntry): Promise<void> {
try { try {
// Add to buffer
analyticsBuffer.entries.push(entry); analyticsBuffer.entries.push(entry);
// Flush if batch size reached
if (analyticsBuffer.entries.length >= CACHE_CONFIG.ANALYTICS_BATCH_SIZE) { if (analyticsBuffer.entries.length >= CACHE_CONFIG.ANALYTICS_BATCH_SIZE) {
await flushAnalyticsBuffer(); await flushAnalyticsBuffer();
} else { } else {
// Schedule periodic flush
scheduleAnalyticsFlush(); scheduleAnalyticsFlush();
} }
} catch (error) { } catch (error) {
@@ -422,7 +419,6 @@ export async function getPerformanceStats(days: number = 30): Promise<{
}> { }> {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Get average metrics
const avgResult = await conn.execute({ const avgResult = await conn.execute({
sql: `SELECT sql: `SELECT
AVG(lcp) as avgLcp, AVG(lcp) as avgLcp,
@@ -472,7 +468,6 @@ export async function getPerformanceStats(days: number = 30): Promise<{
args: [] args: []
}); });
// Get performance by path (only for non-API paths)
const byPathResult = await conn.execute({ const byPathResult = await conn.execute({
sql: `SELECT sql: `SELECT
path, path,

View File

@@ -80,11 +80,9 @@ import {
* In development: ctx.event might be H3Event directly * In development: ctx.event might be H3Event directly
*/ */
function getH3Event(ctx: Context): H3Event { function getH3Event(ctx: Context): H3Event {
// Check if nativeEvent exists (production)
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) { if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
return ctx.event.nativeEvent as H3Event; return ctx.event.nativeEvent as H3Event;
} }
// Otherwise, assume ctx.event is H3Event (development)
return ctx.event as unknown as H3Event; return ctx.event as unknown as H3Event;
} }
@@ -245,7 +243,6 @@ export const authRouter = createTRPCRouter({
try { try {
await conn.execute({ sql: insertQuery, args: insertParams }); await conn.execute({ sql: insertQuery, args: insertParams });
// Also create UserProvider entry for new user
await linkProvider(userId, "github", { await linkProvider(userId, "github", {
providerUserId: login, providerUserId: login,
email: email, email: email,
@@ -300,7 +297,6 @@ export const authRouter = createTRPCRouter({
} catch (error) { } catch (error) {
console.error("[GitHub Callback] Error during OAuth flow:", error); console.error("[GitHub Callback] Error during OAuth flow:", error);
// Log failed OAuth login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.failed", eventType: "auth.login.failed",
@@ -452,7 +448,6 @@ export const authRouter = createTRPCRouter({
args: insertParams args: insertParams
}); });
// Also create UserProvider entry for new user
await linkProvider(userId, "google", { await linkProvider(userId, "google", {
providerUserId: email, providerUserId: email,
email: email, email: email,
@@ -481,7 +476,6 @@ export const authRouter = createTRPCRouter({
} }
} }
// Issue JWT (OAuth defaults to remember me)
const event = getH3Event(ctx); const event = getH3Event(ctx);
const clientIP = getClientIP(event); const clientIP = getClientIP(event);
const userAgent = getUserAgent(event); const userAgent = getUserAgent(event);
@@ -631,7 +625,6 @@ export const authRouter = createTRPCRouter({
} catch (error) { } catch (error) {
console.error("[Email Login] Error during login:", error); console.error("[Email Login] Error during login:", error);
// Log failed email link login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.failed", eventType: "auth.login.failed",
@@ -685,10 +678,7 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Check if there's a valid JWT token with this code // Tokens aren't stored in DB; verify the code against the JWT cookie set when the email was sent
// 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)
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken"); const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
if (!storedToken) { if (!storedToken) {
throw new TRPCError({ 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); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
let payload; let payload;
try { try {
@@ -756,7 +745,6 @@ export const authRouter = createTRPCRouter({
} catch (error) { } catch (error) {
console.error("[Email Code Login] Error during login:", error); console.error("[Email Code Login] Error during login:", error);
// Log failed code login
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.failed", eventType: "auth.login.failed",
@@ -808,7 +796,6 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Get user ID for audit log
const userRes = await conn.execute({ const userRes = await conn.execute({
sql: "SELECT id FROM User WHERE email = ?", sql: "SELECT id FROM User WHERE email = ?",
args: [email] args: [email]
@@ -819,7 +806,6 @@ export const authRouter = createTRPCRouter({
const params = [true, email]; const params = [true, email];
await conn.execute({ sql: query, args: params }); await conn.execute({ sql: query, args: params });
// Log successful email verification
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
userId, userId,
@@ -835,7 +821,6 @@ export const authRouter = createTRPCRouter({
message: "Email verification success, you may close this window" message: "Email verification success, you may close this window"
}; };
} catch (error) { } catch (error) {
// Log failed email verification
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
eventType: "auth.email.verify.complete", eventType: "auth.email.verify.complete",
@@ -864,7 +849,6 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation, rememberMe } = input; const { email, password, passwordConfirmation, rememberMe } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx)); const clientIP = getClientIP(getH3Event(ctx));
await rateLimitRegistration(clientIP, 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); const existingUserId = await findUserByEmail(email);
if (existingUserId) { if (existingUserId) {
// User exists - check if they have a password
const conn = ConnectionFactory(); const conn = ConnectionFactory();
const userCheck = await conn.execute({ const userCheck = await conn.execute({
sql: "SELECT password_hash, provider FROM User WHERE id = ?", sql: "SELECT password_hash, provider FROM User WHERE id = ?",
@@ -916,13 +898,11 @@ export const authRouter = createTRPCRouter({
args: [userId, email, passwordHash, "email"] args: [userId, email, passwordHash, "email"]
}); });
// Create UserProvider entry for email auth
await linkProvider(userId, "email", { await linkProvider(userId, "email", {
providerUserId: email, providerUserId: email,
email: email email: email
}); });
// Issue auth token with client info
const event = getH3Event(ctx); const event = getH3Event(ctx);
const clientIP = getClientIP(event); const clientIP = getClientIP(event);
const userAgent = getUserAgent(event); const userAgent = getUserAgent(event);
@@ -930,13 +910,11 @@ export const authRouter = createTRPCRouter({
await issueAuthToken({ await issueAuthToken({
event, event,
userId, userId,
rememberMe: rememberMe ?? true rememberMe,
}); });
// Set CSRF token
setCSRFToken(event); setCSRFToken(event);
// Log successful registration
await logAuditEvent({ await logAuditEvent({
userId, userId,
eventType: "auth.register.success", eventType: "auth.register.success",
@@ -948,7 +926,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "success" }; return { success: true, message: "success" };
} catch (e) { } catch (e) {
// Log failed registration
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
eventType: "auth.register.failed", eventType: "auth.register.failed",
@@ -976,7 +953,6 @@ export const authRouter = createTRPCRouter({
try { try {
const { email, password, rememberMe } = input; const { email, password, rememberMe } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx)); const clientIP = getClientIP(getH3Event(ctx));
await rateLimitLogin(email, clientIP, getH3Event(ctx)); await rateLimitLogin(email, clientIP, getH3Event(ctx));
@@ -992,9 +968,7 @@ export const authRouter = createTRPCRouter({
const passwordHash = user?.password_hash || null; const passwordHash = user?.password_hash || null;
const passwordMatch = await checkPasswordSafe(password, passwordHash); const passwordMatch = await checkPasswordSafe(password, passwordHash);
// Check all conditions after password verification
if (!user || !passwordHash || !passwordMatch) { if (!user || !passwordHash || !passwordMatch) {
// Record failed login attempt if user exists
if (user?.id) { if (user?.id) {
const lockoutStatus = await recordFailedLogin(user.id); const lockoutStatus = await recordFailedLogin(user.id);
@@ -1032,7 +1006,6 @@ export const authRouter = createTRPCRouter({
} }
} }
// Log failed login attempt
try { try {
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
@@ -1060,7 +1033,6 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Check if account is locked before allowing login
const lockoutCheck = await checkAccountLockout(user.id); const lockoutCheck = await checkAccountLockout(user.id);
if (lockoutCheck.isLocked) { if (lockoutCheck.isLocked) {
const remainingSec = Math.ceil( const remainingSec = Math.ceil(
@@ -1083,22 +1055,18 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Reset failed attempts on successful login
await resetFailedAttempts(user.id); await resetFailedAttempts(user.id);
// Reset rate limits on successful login
await resetLoginRateLimits(email, clientIP); await resetLoginRateLimits(email, clientIP);
// Issue JWT for authenticated user
const event = getH3Event(ctx); const event = getH3Event(ctx);
const userAgent = getUserAgent(event); const userAgent = getUserAgent(event);
await issueAuthToken({ await issueAuthToken({
event, event,
userId: user.id, userId: user.id,
rememberMe: rememberMe ?? false rememberMe,
}); });
// Set CSRF token for authenticated user
setCSRFToken(event); setCSRFToken(event);
// Log successful login (wrap in try-catch to ensure it never blocks auth flow) // Log successful login (wrap in try-catch to ensure it never blocks auth flow)
@@ -1106,7 +1074,7 @@ export const authRouter = createTRPCRouter({
await logAuditEvent({ await logAuditEvent({
userId: user.id, userId: user.id,
eventType: "auth.login.success", eventType: "auth.login.success",
eventData: { method: "password", rememberMe: rememberMe ?? false }, eventData: { method: "password", rememberMe },
ipAddress: clientIP, ipAddress: clientIP,
userAgent, userAgent,
success: true success: true
@@ -1251,7 +1219,6 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email } = input; const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx)); const clientIP = getClientIP(getH3Event(ctx));
await rateLimitPasswordReset(clientIP, 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)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
userId: user.id, userId: user.id,
@@ -1316,7 +1282,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "email sent" }; return { success: true, message: "email sent" };
} catch (error) { } catch (error) {
// Log failed password reset request (only if not rate limited)
if ( if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") !(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
) { ) {
@@ -1371,7 +1336,6 @@ export const authRouter = createTRPCRouter({
} }
try { try {
// Validate and consume the password reset token
const tokenValidation = await validatePasswordResetToken(token); const tokenValidation = await validatePasswordResetToken(token);
if (!tokenValidation) { if (!tokenValidation) {
@@ -1415,10 +1379,8 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Mark token as used
await markPasswordResetTokenUsed(tokenId); await markPasswordResetTokenUsed(tokenId);
// Log successful password reset
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
userId: userId, userId: userId,
@@ -1431,7 +1393,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "success" }; return { success: true, message: "success" };
} catch (error) { } catch (error) {
// Log failed password reset
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
eventType: "auth.password.reset.complete", eventType: "auth.password.reset.complete",
@@ -1459,7 +1420,6 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email } = input; const { email } = input;
// Apply rate limiting
const clientIP = getClientIP(getH3Event(ctx)); const clientIP = getClientIP(getH3Event(ctx));
await rateLimitEmailVerification(clientIP, 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)); const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
await logAuditEvent({ await logAuditEvent({
userId: user.id, userId: user.id,
@@ -1533,7 +1492,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "Verification email sent" }; return { success: true, message: "Verification email sent" };
} catch (error) { } catch (error) {
// Log failed email verification request (only if not rate limited)
if ( if (
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") !(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 params: any[] = [];
let first = true;
if (input.title !== undefined && input.title !== null) { if (input.title !== undefined && input.title !== null) {
query += first ? "title = ?" : ", title = ?"; sets.push("title = ?");
params.push(input.title); params.push(input.title);
first = false;
} }
if (input.subtitle !== undefined && input.subtitle !== null) { if (input.subtitle !== undefined && input.subtitle !== null) {
query += first ? "subtitle = ?" : ", subtitle = ?"; sets.push("subtitle = ?");
params.push(input.subtitle); params.push(input.subtitle);
first = false;
} }
if (input.body !== undefined && input.body !== null) { if (input.body !== undefined && input.body !== null) {
query += first ? "body = ?" : ", body = ?"; sets.push("body = ?");
params.push(input.body); params.push(input.body);
first = false;
} }
if (input.banner_photo !== undefined && input.banner_photo !== null) { 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_") { if (input.banner_photo === "_DELETE_IMAGE_") {
params.push(null); params.push(null);
} else { } else {
params.push(env.VITE_AWS_BUCKET_STRING + input.banner_photo); params.push(env.VITE_AWS_BUCKET_STRING + input.banner_photo);
} }
first = false;
} }
if (input.published !== undefined && input.published !== null) { if (input.published !== undefined && input.published !== null) {
query += first ? "published = ?" : ", published = ?"; sets.push("published = ?");
params.push(input.published); params.push(input.published);
first = false;
} }
if (shouldSetPublishDate) { if (shouldSetPublishDate) {
query += first ? "date = ?" : ", date = ?"; sets.push("date = ?");
params.push(new Date().toISOString()); 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()); params.push(new Date().toISOString());
first = false;
query += first ? "author_id = ?" : ", author_id = ?"; sets.push("author_id = ?");
params.push(input.author_id); params.push(input.author_id);
query += " WHERE id = ?"; let query = "UPDATE Post SET " + sets.join(", ") + " WHERE id = ?";
params.push(input.id); params.push(input.id);
const results = await conn.execute({ sql: query, args: params }); 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}`); 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) => const dmgFiles = response.Contents.filter((obj) =>
obj.Key?.endsWith(".dmg") obj.Key?.endsWith(".dmg")
).sort((a, b) => { ).sort((a, b) => {
@@ -103,7 +102,6 @@ export const downloadsRouter = createTRPCRouter({
} else if (input.asset_name === "inputhalo") { } else if (input.asset_name === "inputhalo") {
fileKey = await getLatestInputHaloDMG(client, bucket); fileKey = await getLatestInputHaloDMG(client, bucket);
} else { } else {
// Use static mapping for other assets
fileKey = assets[input.asset_name]; fileKey = assets[input.asset_name];
if (!fileKey) { if (!fileKey) {

View File

@@ -33,7 +33,6 @@ export const gitActivityRouter = createTRPCRouter({
`github-commits-${input.limit}`, `github-commits-${input.limit}`,
CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS, CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
async () => { async () => {
// Use Events API to get recent push events
const eventsResponse = await fetchWithTimeout( const eventsResponse = await fetchWithTimeout(
`https://api.github.com/users/MikeFreno/events/public?per_page=100`, `https://api.github.com/users/MikeFreno/events/public?per_page=100`,
{ {
@@ -48,7 +47,6 @@ export const gitActivityRouter = createTRPCRouter({
await checkResponse(eventsResponse); await checkResponse(eventsResponse);
const events = await eventsResponse.json(); const events = await eventsResponse.json();
// Collect (repo, sha) pairs from push events up front
const toFetch: { repoName: string; sha: string }[] = []; const toFetch: { repoName: string; sha: string }[] = [];
for (const event of events) { for (const event of events) {
if (event.type !== "PushEvent") continue; 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 // Account-deletion request email — product-aware
// ============================================================ // ============================================================
// //
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit- // Bare "export … from" doesn't bind names locally — import first or the router throws ReferenceError (503s every /api/trpc call)
// 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).
import { import {
DELETION_PRODUCT_SCHEMA, DELETION_PRODUCT_SCHEMA,
deletionCookieName, deletionCookieName,
@@ -256,7 +250,6 @@ export const miscRouter = createTRPCRouter({
lastModified: item.LastModified?.toISOString() || "" lastModified: item.LastModified?.toISOString() || ""
})) || []; })) || [];
// Filter out thumbnail files (ending with -small.ext)
const mainFiles = files.filter( const mainFiles = files.filter(
(file) => !file.key.match(/-small\.(jpg|jpeg|png|gif)$/i) (file) => !file.key.match(/-small\.(jpg|jpeg|png|gif)$/i)
); );
@@ -376,7 +369,6 @@ export const miscRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
// Verify Cloudflare Turnstile token
const turnstileValid = await verifyTurnstileToken( const turnstileValid = await verifyTurnstileToken(
input.turnstileToken, input.turnstileToken,
env.TURNSTILE_SECRET_KEY, env.TURNSTILE_SECRET_KEY,

View File

@@ -10,6 +10,7 @@
*/ */
import { describe, it, expect, mock, beforeEach } from "bun:test"; import { describe, it, expect, mock, beforeEach } from "bun:test";
import { TRPCError } from "@trpc/server";
import type { Client } from "@libsql/client/web"; import type { Client } from "@libsql/client/web";
// Prevent the env/server.ts client-side guard from throwing during tests // 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(); const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
for (const name of MUTATIONS) { for (const name of MUTATIONS) {
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
const re = new RegExp( const re = new RegExp(
`${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`, `${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`,
"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(); const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
for (const name of MUTATIONS) { for (const name of MUTATIONS) {
// Find the block for this mutation and check it references ctx
const re = new RegExp( const re = new RegExp(
`${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`, `${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`,
"s" "s"
@@ -318,12 +317,55 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
} }
}); });
it("bulkUpsert filters exerciseLibrary by userId", async () => { it("upsertExerciseLibrary rejects an exercise owned by another user", async () => {
const source = await Bun.file(import.meta.dir + "/nessa.ts").text(); const mod = await import("./nessa");
const bulkSection = source.match( const conn = makeMockConn([]);
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/ const exercise = {
); id: EXERCISE_ID,
expect(bulkSection).toBeTruthy(); userId: USER_B,
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId"); 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; 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 */ /** Assert that the workout identified by workoutId is owned by userId */
export async function assertWorkoutOwned( export async function assertWorkoutOwned(
conn: Client, conn: Client,
workoutId: string, workoutId: string,
userId: string userId: string
) { ) {
const row = await conn.execute({ await assertOwnedBy(
sql: "SELECT userId FROM workouts WHERE id = ?", conn,
args: [workoutId] "workouts",
}); workoutId,
if (row.rows.length === 0) { userId,
throw new TRPCError({ code: "NOT_FOUND", message: "Workout not found" }); "Workout not found",
} "Not the workout owner"
if ((row.rows[0] as any).userId !== userId) { );
throw new TRPCError({
code: "FORBIDDEN",
message: "Not the workout owner"
});
}
} }
/** Assert that the auth provider record identified by providerId is owned by userId */ /** Assert that the auth provider record identified by providerId is owned by userId */
@@ -34,22 +54,14 @@ export async function assertAuthProviderOwned(
providerId: string, providerId: string,
userId: string userId: string
) { ) {
const row = await conn.execute({ await assertOwnedBy(
sql: "SELECT userId FROM authProviders WHERE id = ?", conn,
args: [providerId] "authProviders",
}); providerId,
if (row.rows.length === 0) { userId,
throw new TRPCError({ "Auth provider not found",
code: "NOT_FOUND", "Not the auth provider owner"
message: "Auth provider not found" );
});
}
if ((row.rows[0] as any).userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not the auth provider owner"
});
}
} }
/** Assert that the exercise library record identified by exerciseId is owned by userId */ /** Assert that the exercise library record identified by exerciseId is owned by userId */
@@ -58,19 +70,14 @@ export async function assertExerciseLibraryOwned(
exerciseId: string, exerciseId: string,
userId: string userId: string
) { ) {
const row = await conn.execute({ await assertOwnedBy(
sql: "SELECT userId FROM exerciseLibrary WHERE id = ?", conn,
args: [exerciseId] "exerciseLibrary",
}); exerciseId,
if (row.rows.length === 0) { userId,
throw new TRPCError({ code: "NOT_FOUND", message: "Exercise not found" }); "Exercise not found",
} "Not the exercise owner"
if ((row.rows[0] as any).userId !== userId) { );
throw new TRPCError({
code: "FORBIDDEN",
message: "Not the exercise owner"
});
}
} }
const paginatedQuerySchema = z.object({ const paginatedQuerySchema = z.object({
@@ -239,6 +246,401 @@ const bulkSchema = z.object({
authProviders: z.array(providerSchema).optional() 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({ export const nessaDbRouter = createTRPCRouter({
health: nessaProcedure.query(async () => { health: nessaProcedure.query(async () => {
try { try {
@@ -1826,356 +2228,41 @@ export const nessaDbRouter = createTRPCRouter({
try { try {
const conn = NessaConnectionFactory(); const conn = NessaConnectionFactory();
if (input.users?.length) { await upsertUsers(conn, ctx.nessaUserId, input.users ?? []);
for (const user of input.users) { await upsertExerciseLibrary(
if (user.id !== ctx.nessaUserId) { conn,
throw new TRPCError({ ctx.nessaUserId,
code: "FORBIDDEN", input.exerciseLibrary ?? []
message: "User mismatch" );
}); await upsertWorkoutPlans(conn, ctx.nessaUserId, input.workoutPlans ?? []);
} await upsertPlanExercises(
await conn.execute({ conn,
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status) ctx.nessaUserId,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) input.planExercises ?? []
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: [ await upsertPlanSets(conn, ctx.nessaUserId, input.planSets ?? []);
user.id, await upsertRoutePoints(conn, ctx.nessaUserId, input.routePoints ?? []);
user.email ?? null, await upsertWorkouts(conn, ctx.nessaUserId, input.workouts ?? []);
user.emailVerified ?? 0, await upsertHeartRateSamples(
user.firstName ?? null, conn,
user.lastName ?? null, ctx.nessaUserId,
user.displayName ?? null, input.heartRateSamples ?? []
user.avatarUrl ?? null, );
user.provider ?? null, await upsertLocationSamples(
user.appleUserId ?? null, conn,
user.status ?? "active" ctx.nessaUserId,
] input.locationSamples ?? []
}); );
} await upsertWorkoutSplits(
} conn,
ctx.nessaUserId,
if (input.exerciseLibrary?.length) { input.workoutSplits ?? []
for (const exercise of input.exerciseLibrary) { );
if (exercise.userId !== ctx.nessaUserId) { await upsertAuthProviders(
throw new TRPCError({ conn,
code: "FORBIDDEN", ctx.nessaUserId,
message: "User mismatch" input.authProviders ?? []
}); );
}
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
]
});
}
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@@ -4,7 +4,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import diff from "fast-diff"; import diff from "fast-diff";
export function createDiffPatch( function createDiffPatch(
oldContent: string, oldContent: string,
newContent: string newContent: string
): string { ): string {
@@ -12,7 +12,7 @@ export function createDiffPatch(
return JSON.stringify(changes); return JSON.stringify(changes);
} }
export function applyDiffPatch(baseContent: string, patchJson: string): string { function applyDiffPatch(baseContent: string, patchJson: string): string {
const changes = JSON.parse(patchJson); const changes = JSON.parse(patchJson);
let result = ""; let result = "";
let position = 0; let position = 0;
@@ -96,7 +96,6 @@ export const postHistoryRouter = createTRPCRouter({
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Verify post exists and user is author
const postCheck = await conn.execute({ const postCheck = await conn.execute({
sql: "SELECT author_id FROM Post WHERE id = ?", sql: "SELECT author_id FROM Post WHERE id = ?",
args: [input.postId] args: [input.postId]

View File

@@ -254,7 +254,6 @@ export const userRouter = createTRPCRouter({
args: [passwordHash, userId] args: [passwordHash, userId]
}); });
// Send email notification about password being set
if (user.email) { if (user.email) {
try { try {
const h3Event = ctx.event.nativeEvent const h3Event = ctx.event.nativeEvent

View File

@@ -6,105 +6,6 @@ import { z } from "zod";
* Schemas for post creation, updating, querying, and interactions * 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 // Post Interactions
// ============================================================================ // ============================================================================
@@ -116,55 +17,8 @@ export const incrementPostReadSchema = z.object({
postId: z.number() 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 // 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 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 * 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 // 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 * Post creation input
*/ */
@@ -97,47 +36,6 @@ export const updatePostSchema = z.object({
attachments: z.string().optional() 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 // CommentReaction Schemas
// ============================================================================ // ============================================================================
@@ -160,94 +58,6 @@ export const reactionTypeSchema = z.enum([
"downVote" "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 // Common Query Schemas
// ============================================================================ // ============================================================================
@@ -259,26 +69,6 @@ export const idSchema = z.object({
id: z.number() 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 // Additional Database Router Schemas
// ============================================================================ // ============================================================================
@@ -343,10 +133,6 @@ export const getUserByIdSchema = z.object({
id: z.string() id: z.string()
}); });
export const getUserPublicDataSchema = z.object({
id: z.string()
});
export const updateUserImageSchema = z.object({ export const updateUserImageSchema = z.object({
id: z.string(), id: z.string(),
imageURL: z.string() imageURL: z.string()
@@ -365,15 +151,6 @@ export const updateUserEmailSchema = z.object({
export type ReactionType = z.infer<typeof reactionTypeSchema>; export type ReactionType = z.infer<typeof reactionTypeSchema>;
export type CreatePostInput = z.infer<typeof createPostSchema>; export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>; 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 GetPostByIdInput = z.infer<typeof getPostByIdSchema>;
export type GetPostByTitleInput = z.infer<typeof getPostByTitleSchema>; export type GetPostByTitleInput = z.infer<typeof getPostByTitleSchema>;
export type GetCommentsByPostIdInput = z.infer< export type GetCommentsByPostIdInput = z.infer<

View File

@@ -65,11 +65,6 @@ export const loginUserSchema = z.object({
rememberMe: z.boolean().optional().default(false) rememberMe: z.boolean().optional().default(false)
}); });
/**
* OAuth provider schema
*/
export const oauthProviderSchema = z.enum(["google", "github"]);
// ============================================================================ // ============================================================================
// Profile Management Schemas // Profile Management Schemas
// ============================================================================ // ============================================================================
@@ -168,20 +163,12 @@ export const deleteAccountSchema = z.object({
password: z.string().min(1, "Password is required to delete account") 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 // Type Exports
// ============================================================================ // ============================================================================
export type RegisterUserInput = z.infer<typeof registerUserSchema>; export type RegisterUserInput = z.infer<typeof registerUserSchema>;
export type LoginUserInput = z.infer<typeof loginUserSchema>; export type LoginUserInput = z.infer<typeof loginUserSchema>;
export type OAuthProvider = z.infer<typeof oauthProviderSchema>;
export type UpdateEmailInput = z.infer<typeof updateEmailSchema>; export type UpdateEmailInput = z.infer<typeof updateEmailSchema>;
export type UpdateDisplayNameInput = z.infer<typeof updateDisplayNameSchema>; export type UpdateDisplayNameInput = z.infer<typeof updateDisplayNameSchema>;
export type UpdateProfileImageInput = z.infer<typeof updateProfileImageSchema>; 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 ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type DeleteAccountInput = z.infer<typeof deleteAccountSchema>; 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 () => { it("should not throw errors on logging failures", async () => {
// This should not throw even if there's an invalid event type
await expect( await expect(
logAuditEvent({ logAuditEvent({
eventType: "invalid.test.event", eventType: "invalid.test.event",
@@ -101,7 +100,6 @@ describe("Audit Logging System", () => {
describe("queryAuditLogs", () => { describe("queryAuditLogs", () => {
beforeEach(async () => { beforeEach(async () => {
// Create test logs
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.success", eventType: "auth.login.success",
eventData: { test: "test-query-1", testUser: "user-1" }, eventData: { test: "test-query-1", testUser: "user-1" },
@@ -199,7 +197,6 @@ describe("Audit Logging System", () => {
describe("getFailedLoginAttempts", () => { describe("getFailedLoginAttempts", () => {
beforeEach(async () => { beforeEach(async () => {
// Create failed login attempts
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.failed", eventType: "auth.login.failed",
@@ -212,7 +209,6 @@ describe("Audit Logging System", () => {
}); });
} }
// Create successful logins (should be excluded)
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.success", eventType: "auth.login.success",
eventData: { test: "test-success-1" }, eventData: { test: "test-success-1" },
@@ -241,7 +237,6 @@ describe("Audit Logging System", () => {
const attemptsIn1h = await getFailedLoginAttempts(1, 100); const attemptsIn1h = await getFailedLoginAttempts(1, 100);
expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5); expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5);
// Recent attempts should be within 1 hour
expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5); expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5);
}); });
}); });
@@ -302,7 +297,6 @@ describe("Audit Logging System", () => {
describe("detectSuspiciousActivity", () => { describe("detectSuspiciousActivity", () => {
beforeEach(async () => { beforeEach(async () => {
// Create suspicious pattern: many failed logins from same IP
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.failed", eventType: "auth.login.failed",
@@ -315,7 +309,6 @@ describe("Audit Logging System", () => {
}); });
} }
// Create normal activity
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.success", eventType: "auth.login.success",
eventData: { test: "test-normal-1" }, eventData: { test: "test-normal-1" },
@@ -344,7 +337,6 @@ describe("Audit Logging System", () => {
it("should return empty array when no suspicious activity", async () => { it("should return empty array when no suspicious activity", async () => {
await cleanupTestLogs(); await cleanupTestLogs();
// Create only successful logins
await logAuditEvent({ await logAuditEvent({
eventType: "auth.login.success", eventType: "auth.login.success",
eventData: { test: "test-clean-1" }, 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); const deleted = await cleanupOldLogs(90);
expect(deleted).toBeGreaterThanOrEqual(1); expect(deleted).toBeGreaterThanOrEqual(1);
@@ -399,7 +390,6 @@ describe("Audit Logging System", () => {
const logsAfter = await queryAuditLogs({ limit: 100 }); const logsAfter = await queryAuditLogs({ limit: 100 });
// Should still have recent logs
expect(logsAfter.length).toBeGreaterThan(0); expect(logsAfter.length).toBeGreaterThan(0);
}); });
}); });

View File

@@ -405,7 +405,6 @@ export async function detectSuspiciousActivity(
const currentIp = currentIpOrMinAttempts as string; const currentIp = currentIpOrMinAttempts as string;
const reasons: string[] = []; const reasons: string[] = [];
// Check for excessive failed logins
const failedAttempts = (await getFailedLoginAttempts( const failedAttempts = (await getFailedLoginAttempts(
userId, userId,
"user_id", "user_id",
@@ -415,7 +414,6 @@ export async function detectSuspiciousActivity(
reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`); 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({ const recentIps = await conn.execute({
sql: `SELECT DISTINCT ip_address FROM AuditLog sql: `SELECT DISTINCT ip_address FROM AuditLog
WHERE user_id = ? 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({ const ipHistory = await conn.execute({
sql: `SELECT COUNT(*) as count FROM AuditLog sql: `SELECT COUNT(*) as count FROM AuditLog
WHERE user_id = ? WHERE user_id = ?

View File

@@ -100,7 +100,6 @@ export async function withCacheAndStale<T>(
const now = Date.now(); const now = Date.now();
const entry = store.get(key) as CacheEntry<T> | undefined; const entry = store.get(key) as CacheEntry<T> | undefined;
// Fresh hit
if (entry && entry.expiresAt > now) return entry.data; if (entry && entry.expiresAt > now) return entry.data;
try { try {
@@ -116,7 +115,6 @@ export async function withCacheAndStale<T>(
console.error(`Error fetching data for cache key "${key}":`, error); console.error(`Error fetching data for cache key "${key}":`, error);
} }
// Stale fallback
if (entry && entry.staleExpiresAt > now) { if (entry && entry.staleExpiresAt > now) {
if (logErrors) console.log(`Serving stale data for cache key "${key}"`); if (logErrors) console.log(`Serving stale data for cache key "${key}"`);
return entry.data; return entry.data;

View File

@@ -254,7 +254,6 @@ describe("Clerk user.created webhook", () => {
describe("Clerk user.updated webhook", () => { describe("Clerk user.updated webhook", () => {
it("updates mutable fields and leaves clerkUserId unchanged", async () => { it("updates mutable fields and leaves clerkUserId unchanged", async () => {
// seed via created
await call(sign(userCreatedPayload())); await call(sign(userCreatedPayload()));
const before = getUserByClerkId("user_abc123"); 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 { export interface DeviceInfo {
deviceName?: string; deviceName?: string;
deviceType?: "desktop" | "mobile" | "tablet"; deviceType?: "desktop" | "mobile" | "tablet";
@@ -8,61 +5,6 @@ export interface DeviceInfo {
os?: string; 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 * Generate a human-readable device description
* @param deviceInfo - Device information * @param deviceInfo - Device information
@@ -85,18 +27,3 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
return parts.length > 0 ? parts.join(" • ") : "Unknown Device"; 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 passwordResetTemplate from "./password-reset.html?raw";
import emailVerificationTemplate from "./email-verification.html?raw"; import emailVerificationTemplate from "./email-verification.html?raw";
import providerLinkedTemplate from "./provider-linked.html?raw"; import providerLinkedTemplate from "./provider-linked.html?raw";
import newDeviceLoginTemplate from "./new-device-login.html?raw";
import passwordSetTemplate from "./password-set.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 { export interface PasswordSetEmailParams {
providerName: string; providerName: string;
setTime: string; setTime: string;

View File

@@ -12,7 +12,6 @@ import {
async function testTimeoutError() { async function testTimeoutError() {
console.log("\n=== Testing Timeout Error ==="); console.log("\n=== Testing Timeout Error ===");
try { try {
// This should timeout after 1ms
await fetchWithTimeout("https://httpbin.org/delay/10", { timeout: 1 }); await fetchWithTimeout("https://httpbin.org/delay/10", { timeout: 1 });
console.log("❌ Should have thrown TimeoutError"); console.log("❌ Should have thrown TimeoutError");
} catch (error) { } catch (error) {
@@ -29,7 +28,6 @@ async function testTimeoutError() {
async function testNetworkError() { async function testNetworkError() {
console.log("\n=== Testing Network Error ==="); console.log("\n=== Testing Network Error ===");
try { try {
// This should fail to connect
await fetchWithTimeout( await fetchWithTimeout(
"https://invalid-domain-that-does-not-exist-12345.com" "https://invalid-domain-that-does-not-exist-12345.com"
); );
@@ -47,7 +45,6 @@ async function testNetworkError() {
async function testAPIError() { async function testAPIError() {
console.log("\n=== Testing API Error ==="); console.log("\n=== Testing API Error ===");
try { try {
// This should return 404
const response = await fetchWithTimeout("https://httpbin.org/status/404"); const response = await fetchWithTimeout("https://httpbin.org/status/404");
await checkResponse(response); await checkResponse(response);
console.log("❌ Should have thrown APIError"); 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.exp).toBe(mockPayload.exp);
expect(result.iat).toBe(mockPayload.iat); expect(result.iat).toBe(mockPayload.iat);
// Verify verifyToken was called with correct options
expect(mockVerifyToken).toHaveBeenCalledWith( expect(mockVerifyToken).toHaveBeenCalledWith(
"valid-clerk-session-token", "valid-clerk-session-token",
expect.objectContaining({ expect.objectContaining({

View File

@@ -33,8 +33,6 @@ export async function verifyNessaToken(
): Promise<NessaAuthPayload> { ): Promise<NessaAuthPayload> {
const payload = await verifyToken(token, { const payload = await verifyToken(token, {
secretKey: env.NESSA_CLERK_SECRET, secretKey: env.NESSA_CLERK_SECRET,
// Optional: restrict to specific issuers / apps
// audience: env.NESSA_CLERK_JWT_ISSUER,
}); });
if (!payload.sub) { if (!payload.sub) {

View File

@@ -34,7 +34,6 @@ export async function linkProvider(
): Promise<UserProvider> { ): Promise<UserProvider> {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Check if provider already linked to this user
const existing = await conn.execute({ const existing = await conn.execute({
sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?", sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?",
args: [userId, provider] args: [userId, provider]
@@ -44,7 +43,6 @@ export async function linkProvider(
throw new Error(`Provider ${provider} already linked to this account`); throw new Error(`Provider ${provider} already linked to this account`);
} }
// Check if provider identity is already used by another user
if (providerData.providerUserId) { if (providerData.providerUserId) {
const conflictCheck = await conn.execute({ const conflictCheck = await conn.execute({
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?", 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(); const id = uuidV4();
await conn.execute({ await conn.execute({
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image) 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({ const result = await conn.execute({
sql: "SELECT * FROM UserProvider WHERE id = ?", sql: "SELECT * FROM UserProvider WHERE id = ?",
args: [id] args: [id]
@@ -85,7 +81,6 @@ export async function linkProvider(
const userProvider = result.rows[0] as unknown as UserProvider; const userProvider = result.rows[0] as unknown as UserProvider;
// Log audit event
await logAuditEvent({ await logAuditEvent({
userId, userId,
eventType: "auth.provider.linked", eventType: "auth.provider.linked",
@@ -99,7 +94,6 @@ export async function linkProvider(
// Send notification email if requested and user has email // Send notification email if requested and user has email
if (options?.sendEmail !== false) { if (options?.sendEmail !== false) {
try { try {
// Get user email
const userResult = await conn.execute({ const userResult = await conn.execute({
sql: "SELECT email FROM User WHERE id = ?", sql: "SELECT email FROM User WHERE id = ?",
args: [userId] args: [userId]
@@ -150,7 +144,6 @@ export async function unlinkProvider(
): Promise<void> { ): Promise<void> {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Check how many providers this user has
const providersResult = await conn.execute({ const providersResult = await conn.execute({
sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?", sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?",
args: [userId] args: [userId]
@@ -164,7 +157,6 @@ export async function unlinkProvider(
); );
} }
// Delete provider
const result = await conn.execute({ const result = await conn.execute({
sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?", sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?",
args: [userId, provider] args: [userId, provider]
@@ -174,7 +166,6 @@ export async function unlinkProvider(
throw new Error(`Provider ${provider} not found for this user`); throw new Error(`Provider ${provider} not found for this user`);
} }
// Log audit event
await logAuditEvent({ await logAuditEvent({
userId, userId,
eventType: "auth.provider.unlinked", eventType: "auth.provider.unlinked",
@@ -261,7 +252,6 @@ export async function findUserByProviderEmail(
export async function findUserByEmail(email: string): Promise<string | null> { export async function findUserByEmail(email: string): Promise<string | null> {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// First check User table
const userResult = await conn.execute({ const userResult = await conn.execute({
sql: "SELECT id FROM User WHERE email = ?", sql: "SELECT id FROM User WHERE email = ?",
args: [email] args: [email]
@@ -271,7 +261,6 @@ export async function findUserByEmail(email: string): Promise<string | null> {
return (userResult.rows[0] as any).id; return (userResult.rows[0] as any).id;
} }
// Then check UserProvider table
const providerResult = await conn.execute({ const providerResult = await conn.execute({
sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1", sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1",
args: [email] args: [email]

View File

@@ -21,7 +21,6 @@ describe("CSRF Protection", () => {
const token = generateCSRFToken(); const token = generateCSRFToken();
expect(token).toBeDefined(); expect(token).toBeDefined();
expect(typeof token).toBe("string"); expect(typeof token).toBe("string");
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
expect(token).toMatch( 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 /^[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", () => { it("should generate cryptographically secure tokens", () => {
// Generate multiple tokens and ensure no collisions
const tokens = new Set<string>(); const tokens = new Set<string>();
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
tokens.add(generateCSRFToken()); tokens.add(generateCSRFToken());
@@ -50,7 +48,6 @@ describe("CSRF Protection", () => {
expect(token).toBeDefined(); expect(token).toBeDefined();
expect(typeof token).toBe("string"); expect(typeof token).toBe("string");
// Token should be a UUID
expect(token).toMatch( 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 /^[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 invalidToken1 = "b".repeat(36);
const invalidToken2 = "b".repeat(35) + "a"; const invalidToken2 = "b".repeat(35) + "a";
// Test timing for completely different tokens
const event1 = createMockEvent({ const event1 = createMockEvent({
headers: { "x-csrf-token": invalidToken1 }, headers: { "x-csrf-token": invalidToken1 },
cookies: { "csrf-token": validToken } cookies: { "csrf-token": validToken }
@@ -132,7 +128,6 @@ describe("CSRF Protection", () => {
validateCSRFToken(event1); validateCSRFToken(event1);
const time1 = performance.now() - start1; const time1 = performance.now() - start1;
// Test timing for tokens that differ only at the end
const event2 = createMockEvent({ const event2 = createMockEvent({
headers: { "x-csrf-token": invalidToken2 }, headers: { "x-csrf-token": invalidToken2 },
cookies: { "csrf-token": validToken } cookies: { "csrf-token": validToken }
@@ -142,7 +137,6 @@ describe("CSRF Protection", () => {
validateCSRFToken(event2); validateCSRFToken(event2);
const time2 = performance.now() - start2; const time2 = performance.now() - start2;
// Timing difference should be minimal (less than 1ms)
// This tests for constant-time comparison // This tests for constant-time comparison
const timeDiff = Math.abs(time1 - time2); const timeDiff = Math.abs(time1 - time2);
expect(timeDiff).toBeLessThan(1); expect(timeDiff).toBeLessThan(1);
@@ -161,7 +155,6 @@ describe("CSRF Protection", () => {
describe("CSRF Attack Scenarios", () => { describe("CSRF Attack Scenarios", () => {
it("should prevent basic CSRF attack", () => { it("should prevent basic CSRF attack", () => {
// Attacker doesn't have access to the CSRF token cookie
const attackEvent = createMockEvent({ const attackEvent = createMockEvent({
headers: { "x-csrf-token": "attacker-guessed-token" } headers: { "x-csrf-token": "attacker-guessed-token" }
}); });
@@ -174,7 +167,6 @@ describe("CSRF Protection", () => {
const token1 = generateCSRFToken(); const token1 = generateCSRFToken();
const token2 = generateCSRFToken(); const token2 = generateCSRFToken();
// User has token1, attacker tries to use token2
const event = createMockEvent({ const event = createMockEvent({
headers: { "x-csrf-token": token2 }, headers: { "x-csrf-token": token2 },
cookies: { "csrf-token": token1 } cookies: { "csrf-token": token1 }
@@ -198,7 +190,6 @@ describe("CSRF Protection", () => {
}); });
it("should prevent replay attacks with old tokens", () => { it("should prevent replay attacks with old tokens", () => {
// Simulate an old token that was captured
const oldToken = "old-captured-token-12345"; const oldToken = "old-captured-token-12345";
const event = createMockEvent({ const event = createMockEvent({
@@ -206,10 +197,8 @@ describe("CSRF Protection", () => {
cookies: { "csrf-token": oldToken } 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); 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()); tokens.push(generateCSRFToken());
} }
// Check for sequential patterns
for (let i = 1; i < tokens.length; i++) { for (let i = 1; i < tokens.length; i++) {
// Tokens should not be incrementing
expect(tokens[i]).not.toBe( expect(tokens[i]).not.toBe(
String(Number(tokens[i - 1].replace(/-/g, "")) + 1) String(Number(tokens[i - 1].replace(/-/g, "")) + 1)
); );
@@ -281,11 +268,9 @@ describe("CSRF Protection", () => {
it("should generate tokens with sufficient entropy", () => { it("should generate tokens with sufficient entropy", () => {
const token = generateCSRFToken(); const token = generateCSRFToken();
// UUID without dashes should be 32 hex characters
const hexString = token.replace(/-/g, ""); const hexString = token.replace(/-/g, "");
expect(hexString).toMatch(/^[0-9a-f]{32}$/i); expect(hexString).toMatch(/^[0-9a-f]{32}$/i);
// Check that not all characters are the same
const uniqueChars = new Set(hexString.split("")); const uniqueChars = new Set(hexString.split(""));
expect(uniqueChars.size).toBeGreaterThan(5); expect(uniqueChars.size).toBeGreaterThan(5);
}); });
@@ -299,7 +284,6 @@ describe("CSRF Protection", () => {
} }
const duration = performance.now() - start; const duration = performance.now() - start;
// Should generate 1000 tokens in less than 100ms
expect(duration).toBeLessThan(100); expect(duration).toBeLessThan(100);
}); });
@@ -316,7 +300,6 @@ describe("CSRF Protection", () => {
} }
const duration = performance.now() - start; const duration = performance.now() - start;
// Should validate 10000 tokens in less than 100ms
expect(duration).toBeLessThan(100); expect(duration).toBeLessThan(100);
}); });
}); });
@@ -402,7 +385,6 @@ describe("CSRF Protection", () => {
const sessionAToken = generateCSRFToken(); const sessionAToken = generateCSRFToken();
const sessionBToken = generateCSRFToken(); const sessionBToken = generateCSRFToken();
// Session A's cookie with Session B's header token
const event = createMockEvent({ const event = createMockEvent({
headers: { "x-csrf-token": sessionBToken }, headers: { "x-csrf-token": sessionBToken },
cookies: { "csrf-token": sessionAToken } cookies: { "csrf-token": sessionAToken }
@@ -522,12 +504,10 @@ describe("CSRF Protection", () => {
it("should issue CSRF token on setCSRFToken then validate it", () => { it("should issue CSRF token on setCSRFToken then validate it", () => {
const event = createMockEvent({}); const event = createMockEvent({});
// Step 1: Login issues CSRF token
const token = setCSRFToken(event); const token = setCSRFToken(event);
expect(token).toBeDefined(); expect(token).toBeDefined();
expect(typeof token).toBe("string"); expect(typeof token).toBe("string");
// Step 2: Subsequent mutation sends token back
const mutationEvent = createMockEvent({ const mutationEvent = createMockEvent({
headers: { "x-csrf-token": token }, headers: { "x-csrf-token": token },
cookies: { "csrf-token": token } cookies: { "csrf-token": token }
@@ -538,7 +518,6 @@ describe("CSRF Protection", () => {
}); });
it("should reject cross-origin POST without CSRF token", () => { it("should reject cross-origin POST without CSRF token", () => {
// Simulated cross-site POST: attacker can read cookies but not set headers
const attackEvent = createMockEvent({ const attackEvent = createMockEvent({
// No x-csrf-token header (cross-origin requests can't set custom headers) // No x-csrf-token header (cross-origin requests can't set custom headers)
cookies: { "csrf-token": "victim-token" } cookies: { "csrf-token": "victim-token" }

View File

@@ -59,7 +59,6 @@ describe("Input Validation and Injection Prevention", () => {
for (const email of sqlEmails) { for (const email of sqlEmails) {
// Either reject as invalid, or it's properly escaped in queries // Either reject as invalid, or it's properly escaped in queries
const isValid = isValidEmail(email); const isValid = isValidEmail(email);
// Test documents the behavior
expect(typeof isValid).toBe("boolean"); expect(typeof isValid).toBe("boolean");
} }
}); });
@@ -68,7 +67,6 @@ describe("Input Validation and Injection Prevention", () => {
const longEmail = "a".repeat(1000) + "@example.com"; const longEmail = "a".repeat(1000) + "@example.com";
const result = isValidEmail(longEmail); const result = isValidEmail(longEmail);
// Should handle gracefully
expect(typeof result).toBe("boolean"); expect(typeof result).toBe("boolean");
}); });
@@ -130,7 +128,6 @@ describe("Input Validation and Injection Prevention", () => {
it("should use parameterized queries for user authentication", async () => { it("should use parameterized queries for user authentication", async () => {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Test that SQL injection attempts don't work
const maliciousEmail = "admin'--"; const maliciousEmail = "admin'--";
try { try {
@@ -140,7 +137,6 @@ describe("Input Validation and Injection Prevention", () => {
args: [maliciousEmail] args: [maliciousEmail]
}); });
// Should return no results (no user with that exact email)
expect(result.rows.length).toBe(0); expect(result.rows.length).toBe(0);
} catch (error) { } catch (error) {
// If error, ensure it's not a SQL 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) { for (const payload of SQL_INJECTION_PAYLOADS) {
try { try {
// Test various injection points
await conn.execute({ await conn.execute({
sql: "SELECT * FROM User WHERE email = ?", sql: "SELECT * FROM User WHERE email = ?",
args: [payload] args: [payload]
@@ -164,7 +159,6 @@ describe("Input Validation and Injection Prevention", () => {
args: [payload] args: [payload]
}); });
// Queries should complete without SQL errors
expect(true).toBe(true); expect(true).toBe(true);
} catch (error: any) { } catch (error: any) {
// If error occurs, should not be SQL injection syntax error // If error occurs, should not be SQL injection syntax error
@@ -184,10 +178,8 @@ describe("Input Validation and Injection Prevention", () => {
args: [unionPayload] args: [unionPayload]
}); });
// Should not return password hashes
if (result.rows.length > 0) { if (result.rows.length > 0) {
for (const row of result.rows) { for (const row of result.rows) {
// Ensure we don't get password_hash column
expect(row).not.toHaveProperty("password_hash"); 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 () => { it("should prevent blind SQL injection timing attacks", async () => {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Timing-based payload
const timingPayload = "admin' AND SLEEP(5)--"; const timingPayload = "admin' AND SLEEP(5)--";
const start = performance.now(); const start = performance.now();
@@ -210,18 +201,15 @@ describe("Input Validation and Injection Prevention", () => {
args: [timingPayload] args: [timingPayload]
}); });
} catch (error) { } catch (error) {
// Ignore errors
} }
const duration = performance.now() - start; const duration = performance.now() - start;
// Should not delay for 5 seconds
expect(duration).toBeLessThan(1000); expect(duration).toBeLessThan(1000);
}); });
it("should prevent second-order SQL injection", async () => { it("should prevent second-order SQL injection", async () => {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Store malicious data
const maliciousName = "admin'--"; const maliciousName = "admin'--";
try { 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({ const result = await conn.execute({
sql: "SELECT display_name FROM User WHERE email = ?", sql: "SELECT display_name FROM User WHERE email = ?",
args: ["test-sqli@example.com"] args: ["test-sqli@example.com"]
@@ -244,13 +231,11 @@ describe("Input Validation and Injection Prevention", () => {
expect(result.rows.length).toBeGreaterThanOrEqual(0); expect(result.rows.length).toBeGreaterThanOrEqual(0);
// Cleanup
await conn.execute({ await conn.execute({
sql: "DELETE FROM User WHERE email = ?", sql: "DELETE FROM User WHERE email = ?",
args: ["test-sqli@example.com"] args: ["test-sqli@example.com"]
}); });
} catch (error) { } catch (error) {
// Should not have SQL syntax errors
expect(error).toBeDefined(); expect(error).toBeDefined();
} }
}); });
@@ -260,7 +245,6 @@ describe("Input Validation and Injection Prevention", () => {
it("should identify potentially dangerous XSS patterns", () => { it("should identify potentially dangerous XSS patterns", () => {
// These payloads should be handled by frontend sanitization // These payloads should be handled by frontend sanitization
for (const payload of XSS_PAYLOADS) { for (const payload of XSS_PAYLOADS) {
// Document that these patterns exist
expect(payload).toBeDefined(); expect(payload).toBeDefined();
expect(typeof payload).toBe("string"); expect(typeof payload).toBe("string");
@@ -272,11 +256,9 @@ describe("Input Validation and Injection Prevention", () => {
it("should handle script tags in user input", () => { it("should handle script tags in user input", () => {
const scriptInput = "<script>alert('XSS')</script>"; const scriptInput = "<script>alert('XSS')</script>";
// Validation should not crash
const nameValid = isValidDisplayName(scriptInput); const nameValid = isValidDisplayName(scriptInput);
expect(typeof nameValid).toBe("boolean"); expect(typeof nameValid).toBe("boolean");
// Email validation
const emailValid = isValidEmail(scriptInput); const emailValid = isValidEmail(scriptInput);
expect(typeof emailValid).toBe("boolean"); expect(typeof emailValid).toBe("boolean");
}); });
@@ -450,7 +432,6 @@ describe("Input Validation and Injection Prevention", () => {
expect(typeof emailValid).toBe("boolean"); expect(typeof emailValid).toBe("boolean");
expect(typeof nameValid).toBe("boolean"); expect(typeof nameValid).toBe("boolean");
// Should complete quickly (no ReDoS)
expect(duration).toBeLessThan(100); expect(duration).toBeLessThan(100);
}); });
@@ -508,14 +489,12 @@ describe("Input Validation and Injection Prevention", () => {
}); });
it("should not be vulnerable to ReDoS attacks", () => { it("should not be vulnerable to ReDoS attacks", () => {
// ReDoS payload with many repetitions
const redosPayload = "a".repeat(1000) + "!"; const redosPayload = "a".repeat(1000) + "!";
const start = performance.now(); const start = performance.now();
validatePassword(redosPayload); validatePassword(redosPayload);
const duration = performance.now() - start; const duration = performance.now() - start;
// Should complete quickly
expect(duration).toBeLessThan(100); expect(duration).toBeLessThan(100);
}); });
}); });

View File

@@ -20,7 +20,6 @@ describe("Password Security", () => {
expect(hash).toBeDefined(); expect(hash).toBeDefined();
expect(typeof hash).toBe("string"); expect(typeof hash).toBe("string");
// Bcrypt hashes start with $2b$ or $2a$
expect(hash).toMatch(/^\$2[ab]\$/); expect(hash).toMatch(/^\$2[ab]\$/);
}); });
@@ -36,7 +35,6 @@ describe("Password Security", () => {
const password = "TestPassword123!"; const password = "TestPassword123!";
const hash = await hashPassword(password); const hash = await hashPassword(password);
// Bcrypt hashes are 60 characters long
expect(hash.length).toBe(60); expect(hash.length).toBe(60);
}); });
@@ -132,32 +130,26 @@ describe("Password Security", () => {
const password = "TestPassword123!"; const password = "TestPassword123!";
const hash = await hashPassword(password); const hash = await hashPassword(password);
// Measure time for correct password
const { duration: correctDuration } = await measureTime(() => const { duration: correctDuration } = await measureTime(() =>
checkPasswordSafe(password, hash) checkPasswordSafe(password, hash)
); );
// Measure time for incorrect password
const { duration: incorrectDuration } = await measureTime(() => const { duration: incorrectDuration } = await measureTime(() =>
checkPasswordSafe("WrongPassword123!", hash) checkPasswordSafe("WrongPassword123!", hash)
); );
// Bcrypt comparison should take similar time regardless
const timingDifference = Math.abs(correctDuration - incorrectDuration); const timingDifference = Math.abs(correctDuration - incorrectDuration);
// Allow reasonable variance (bcrypt is inherently slow)
expect(timingDifference).toBeLessThan(50); expect(timingDifference).toBeLessThan(50);
}); });
it("should handle null hash without timing leak", async () => { it("should handle null hash without timing leak", async () => {
const password = "TestPassword123!"; const password = "TestPassword123!";
// Measure time for null hash
const { result: result1, duration: duration1 } = await measureTime(() => const { result: result1, duration: duration1 } = await measureTime(() =>
checkPasswordSafe(password, null) checkPasswordSafe(password, null)
); );
// Measure time for undefined hash
const { result: result2, duration: duration2 } = await measureTime(() => const { result: result2, duration: duration2 } = await measureTime(() =>
checkPasswordSafe(password, undefined) checkPasswordSafe(password, undefined)
); );
@@ -165,7 +157,6 @@ describe("Password Security", () => {
expect(result1).toBe(false); expect(result1).toBe(false);
expect(result2).toBe(false); expect(result2).toBe(false);
// Should take similar time
const timingDifference = Math.abs(duration1 - duration2); const timingDifference = Math.abs(duration1 - duration2);
expect(timingDifference).toBeLessThan(50); expect(timingDifference).toBeLessThan(50);
}); });
@@ -178,7 +169,6 @@ describe("Password Security", () => {
checkPasswordSafe(password, null) checkPasswordSafe(password, null)
); );
// Should take at least a few milliseconds (bcrypt is slow)
expect(duration).toBeGreaterThan(1); expect(duration).toBeGreaterThan(1);
}); });
@@ -186,12 +176,10 @@ describe("Password Security", () => {
const password = "TestPassword123!"; const password = "TestPassword123!";
const hash = await hashPassword(password); const hash = await hashPassword(password);
// User exists
const { duration: existsDuration } = await measureTime(() => const { duration: existsDuration } = await measureTime(() =>
checkPasswordSafe("WrongPassword", hash) checkPasswordSafe("WrongPassword", hash)
); );
// User doesn't exist (null hash)
const { duration: notExistsDuration } = await measureTime(() => const { duration: notExistsDuration } = await measureTime(() =>
checkPasswordSafe("WrongPassword", null) checkPasswordSafe("WrongPassword", null)
); );
@@ -280,9 +268,9 @@ describe("Password Security", () => {
}); });
it("should calculate password strength correctly", () => { it("should calculate password strength correctly", () => {
const fairPassword = "MyP@ssw0rd12"; // 12 chars const fairPassword = "MyP@ssw0rd12";
const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars const goodPassword = "MyStr0ng!P@ssw0rd";
const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123";
expect(validatePassword(fairPassword).strength).toBe("fair"); expect(validatePassword(fairPassword).strength).toBe("fair");
expect(validatePassword(goodPassword).strength).toBe("good"); expect(validatePassword(goodPassword).strength).toBe("good");
@@ -337,7 +325,6 @@ describe("Password Security", () => {
const password = "TestPassword123!"; const password = "TestPassword123!";
const hash = await hashPassword(password); const hash = await hashPassword(password);
// Measure time for multiple checks (simulating brute force)
const start = performance.now(); const start = performance.now();
const attempts = 10; const attempts = 10;
@@ -348,7 +335,6 @@ describe("Password Security", () => {
const duration = performance.now() - start; const duration = performance.now() - start;
const avgPerAttempt = duration / attempts; const avgPerAttempt = duration / attempts;
// Each attempt should take significant time (bcrypt is slow)
// This makes brute force impractical // This makes brute force impractical
expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt 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 () => { it("should prevent rainbow table attacks with unique salts", async () => {
const password = "CommonPassword123!"; const password = "CommonPassword123!";
// Generate multiple hashes for same password
const hashes = await Promise.all( const hashes = await Promise.all(
Array.from({ length: 10 }, () => hashPassword(password)) Array.from({ length: 10 }, () => hashPassword(password))
); );
// All hashes should be unique (different salts)
const uniqueHashes = new Set(hashes); const uniqueHashes = new Set(hashes);
expect(uniqueHashes.size).toBe(10); expect(uniqueHashes.size).toBe(10);
}); });
it("should prevent password spraying with validation", () => { it("should prevent password spraying with validation", () => {
// Common passwords that should be rejected
const commonPasswords = [ const commonPasswords = [
"Password123!", "Password123!",
"Welcome123!", "Welcome123!",
@@ -382,7 +365,6 @@ describe("Password Security", () => {
}); });
it("should resist dictionary attacks", () => { it("should resist dictionary attacks", () => {
// Dictionary words that should be caught
const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"]; const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"];
for (const password of dictionaryBased) { for (const password of dictionaryBased) {
@@ -394,7 +376,7 @@ describe("Password Security", () => {
describe("Edge Cases", () => { describe("Edge Cases", () => {
it("should handle very long passwords", async () => { 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 hash = await hashPassword(longPassword);
const match = await checkPassword(longPassword, hash); const match = await checkPassword(longPassword, hash);
@@ -413,7 +395,6 @@ describe("Password Security", () => {
const hash = await hashPassword(nullBytePassword); const hash = await hashPassword(nullBytePassword);
const match = await checkPassword(nullBytePassword, hash); const match = await checkPassword(nullBytePassword, hash);
// Behavior may vary - just ensure no crash
expect(typeof match).toBe("boolean"); expect(typeof match).toBe("boolean");
}); });
@@ -450,7 +431,6 @@ describe("Password Security", () => {
const duration = performance.now() - start; const duration = performance.now() - start;
// Bcrypt should be slow enough to deter brute force // Bcrypt should be slow enough to deter brute force
// With 10 rounds, should take at least a few milliseconds
expect(duration).toBeGreaterThan(5); expect(duration).toBeGreaterThan(5);
// But not too slow for normal operation // But not too slow for normal operation
expect(duration).toBeLessThan(500); expect(duration).toBeLessThan(500);
@@ -467,11 +447,9 @@ describe("Password Security", () => {
durations.push(performance.now() - start); durations.push(performance.now() - start);
} }
// Timing should be relatively consistent
const avg = durations.reduce((a, b) => a + b, 0) / durations.length; const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg))); const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg)));
// Allow reasonable variance
expect(maxDeviation).toBeLessThan(avg * 0.5); expect(maxDeviation).toBeLessThan(avg * 0.5);
}); });
@@ -484,7 +462,6 @@ describe("Password Security", () => {
} }
const duration = performance.now() - start; const duration = performance.now() - start;
// Validation is CPU-bound but should be fast
expect(duration).toBeLessThan(100); expect(duration).toBeLessThan(100);
}); });
}); });
@@ -494,12 +471,9 @@ describe("Password Security", () => {
const password = "TestPassword123!"; const password = "TestPassword123!";
const hash = await hashPassword(password); const hash = await hashPassword(password);
// Check that hash uses correct salt rounds
// Bcrypt format: $2b$rounds$salthash
const parts = hash.split("$"); const parts = hash.split("$");
const rounds = parseInt(parts[2]); const rounds = parseInt(parts[2]);
// Should use 10 rounds (from password.ts)
expect(rounds).toBe(10); expect(rounds).toBe(10);
}); });
@@ -509,17 +483,14 @@ describe("Password Security", () => {
Array.from({ length: 100 }, () => hashPassword(password)) Array.from({ length: 100 }, () => hashPassword(password))
); );
// Extract salts from hashes
const salts = hashes.map((hash) => { const salts = hashes.map((hash) => {
const parts = hash.split("$"); const parts = hash.split("$");
return parts[3].substring(0, 22); // Salt is 22 characters return parts[3].substring(0, 22); // Salt is 22 characters
}); });
// All salts should be unique
const uniqueSalts = new Set(salts); const uniqueSalts = new Set(salts);
expect(uniqueSalts.size).toBe(100); expect(uniqueSalts.size).toBe(100);
// Check for patterns in salts (should be random)
for (let i = 1; i < salts.length; i++) { for (let i = 1; i < salts.length; i++) {
// Salts should not be sequential or predictable // Salts should not be sequential or predictable
expect(salts[i]).not.toBe(salts[i - 1]); expect(salts[i]).not.toBe(salts[i - 1]);

View File

@@ -60,12 +60,10 @@ describe("Rate Limiting", () => {
const maxAttempts = 3; const maxAttempts = 3;
const windowMs = 60000; const windowMs = 60000;
// Use up all attempts
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
await checkRateLimit(identifier, maxAttempts, windowMs); await checkRateLimit(identifier, maxAttempts, windowMs);
} }
// Next attempt should throw
try { try {
await checkRateLimit(identifier, maxAttempts, windowMs); await checkRateLimit(identifier, maxAttempts, windowMs);
expect.unreachable("Should have thrown"); expect.unreachable("Should have thrown");
@@ -79,7 +77,6 @@ describe("Rate Limiting", () => {
const maxAttempts = 2; const maxAttempts = 2;
const windowMs = 60000; const windowMs = 60000;
// Use up all attempts
await checkRateLimit(identifier, maxAttempts, windowMs); await checkRateLimit(identifier, maxAttempts, windowMs);
await checkRateLimit(identifier, maxAttempts, windowMs); await checkRateLimit(identifier, maxAttempts, windowMs);
@@ -99,12 +96,10 @@ describe("Rate Limiting", () => {
const maxAttempts = 3; const maxAttempts = 3;
const windowMs = 500; // 500ms window for testing const windowMs = 500; // 500ms window for testing
// Use up all attempts
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
await checkRateLimit(identifier, maxAttempts, windowMs); await checkRateLimit(identifier, maxAttempts, windowMs);
} }
// Should be blocked immediately after
try { try {
await checkRateLimit(identifier, maxAttempts, windowMs); await checkRateLimit(identifier, maxAttempts, windowMs);
expect.unreachable("Should have thrown"); expect.unreachable("Should have thrown");
@@ -112,10 +107,8 @@ describe("Rate Limiting", () => {
expect(error).toBeInstanceOf(TRPCError); expect(error).toBeInstanceOf(TRPCError);
} }
// Wait for window to expire
await new Promise((resolve) => setTimeout(resolve, 600)); await new Promise((resolve) => setTimeout(resolve, 600));
// Should be allowed again
const remaining = await checkRateLimit(identifier, maxAttempts, windowMs); const remaining = await checkRateLimit(identifier, maxAttempts, windowMs);
expect(remaining).toBe(maxAttempts - 1); expect(remaining).toBe(maxAttempts - 1);
}); });
@@ -125,13 +118,11 @@ describe("Rate Limiting", () => {
const maxAttempts = 10; const maxAttempts = 10;
const windowMs = 60000; const windowMs = 60000;
// Simulate concurrent requests
const results: number[] = []; const results: number[] = [];
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
results.push(await checkRateLimit(identifier, maxAttempts, windowMs)); 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]); 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 id1 = uniqueId("test1");
const id2 = uniqueId("test2"); const id2 = uniqueId("test2");
// Use up attempts for id1
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
await checkRateLimit(id1, maxAttempts, windowMs); await checkRateLimit(id1, maxAttempts, windowMs);
} }
// id1 should be blocked
try { try {
await checkRateLimit(id1, maxAttempts, windowMs); await checkRateLimit(id1, maxAttempts, windowMs);
expect.unreachable("Should have thrown"); expect.unreachable("Should have thrown");
@@ -155,7 +144,6 @@ describe("Rate Limiting", () => {
expect(error).toBeInstanceOf(TRPCError); expect(error).toBeInstanceOf(TRPCError);
} }
// id2 should still work
const remaining = await checkRateLimit(id2, maxAttempts, windowMs); const remaining = await checkRateLimit(id2, maxAttempts, windowMs);
expect(remaining).toBe(maxAttempts - 1); expect(remaining).toBe(maxAttempts - 1);
}); });
@@ -225,12 +213,10 @@ describe("Rate Limiting", () => {
const email = `test-${Date.now()}@example.com`; const email = `test-${Date.now()}@example.com`;
// IP rate limiting is skipped in test/dev, so only email limit applies // 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++) { for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
await rateLimitLogin(email, ip); await rateLimitLogin(email, ip);
} }
// Next attempt should fail due to email limit
try { try {
await rateLimitLogin(email, ip); await rateLimitLogin(email, ip);
expect.unreachable("Should have thrown"); expect.unreachable("Should have thrown");
@@ -242,12 +228,10 @@ describe("Rate Limiting", () => {
it("should limit by email independently of IP", async () => { it("should limit by email independently of IP", async () => {
const email = `test-${Date.now()}@example.com`; const email = `test-${Date.now()}@example.com`;
// Use different IPs but same email
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) { for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
await rateLimitLogin(email, randomIP()); await rateLimitLogin(email, randomIP());
} }
// Next attempt with different IP should still fail due to email limit
try { try {
await rateLimitLogin(email, randomIP()); await rateLimitLogin(email, randomIP());
expect.unreachable("Should have thrown"); expect.unreachable("Should have thrown");
@@ -260,13 +244,11 @@ describe("Rate Limiting", () => {
const ip = randomIP(); const ip = randomIP();
// In test/dev, IP rate limiting is skipped // In test/dev, IP rate limiting is skipped
// Should allow many different emails from same IP
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const email = `test${i}-${Date.now()}@example.com`; const email = `test${i}-${Date.now()}@example.com`;
await rateLimitLogin(email, ip); await rateLimitLogin(email, ip);
} }
// Should not throw since IP limits are disabled in test/dev
expect(true).toBe(true); expect(true).toBe(true);
}); });
}); });
@@ -276,12 +258,10 @@ describe("Rate Limiting", () => {
const ip = randomIP(); const ip = randomIP();
// IP rate limiting is skipped in test/dev // IP rate limiting is skipped in test/dev
// Should allow many attempts
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await rateLimitPasswordReset(ip); await rateLimitPasswordReset(ip);
} }
// Should not throw in test/dev
expect(true).toBe(true); expect(true).toBe(true);
}); });
@@ -304,12 +284,10 @@ describe("Rate Limiting", () => {
const ip = randomIP(); const ip = randomIP();
// IP rate limiting is skipped in test/dev // IP rate limiting is skipped in test/dev
// Should allow many attempts
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await rateLimitRegistration(ip); await rateLimitRegistration(ip);
} }
// Should not throw in test/dev
expect(true).toBe(true); expect(true).toBe(true);
}); });
}); });
@@ -319,12 +297,10 @@ describe("Rate Limiting", () => {
const ip = randomIP(); const ip = randomIP();
// IP rate limiting is skipped in test/dev // IP rate limiting is skipped in test/dev
// Should allow many attempts
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await rateLimitEmailVerification(ip); await rateLimitEmailVerification(ip);
} }
// Should not throw in test/dev
expect(true).toBe(true); expect(true).toBe(true);
}); });
}); });
@@ -334,7 +310,6 @@ describe("Rate Limiting", () => {
const email = "victim@example.com"; const email = "victim@example.com";
const attackerIP = "1.2.3.4"; const attackerIP = "1.2.3.4";
// Simulate brute force attack
let blockedAtAttempt = 0; let blockedAtAttempt = 0;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
try { try {
@@ -347,7 +322,6 @@ describe("Rate Limiting", () => {
} }
} }
// Should be blocked before 10 attempts
expect(blockedAtAttempt).toBeLessThan(10); expect(blockedAtAttempt).toBeLessThan(10);
expect(blockedAtAttempt).toBeGreaterThan(0); expect(blockedAtAttempt).toBeGreaterThan(0);
}); });
@@ -355,7 +329,6 @@ describe("Rate Limiting", () => {
it("should prevent distributed brute force from multiple IPs", async () => { it("should prevent distributed brute force from multiple IPs", async () => {
const email = "victim@example.com"; const email = "victim@example.com";
// Simulate distributed attack from different IPs
let blockedAtAttempt = 0; let blockedAtAttempt = 0;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
try { try {
@@ -368,7 +341,6 @@ describe("Rate Limiting", () => {
} }
} }
// Should be blocked at email limit (3 attempts)
expect(blockedAtAttempt).toBeLessThanOrEqual( expect(blockedAtAttempt).toBeLessThanOrEqual(
RATE_LIMITS.LOGIN_EMAIL.maxAttempts RATE_LIMITS.LOGIN_EMAIL.maxAttempts
); );
@@ -383,7 +355,6 @@ describe("Rate Limiting", () => {
await rateLimitRegistration(attackerIP); await rateLimitRegistration(attackerIP);
} }
// Should not block in test/dev (IP limits disabled)
expect(true).toBe(true); expect(true).toBe(true);
}); });
@@ -396,7 +367,6 @@ describe("Rate Limiting", () => {
await rateLimitPasswordReset(attackerIP); await rateLimitPasswordReset(attackerIP);
} }
// Should not block in test/dev (IP limits disabled)
expect(true).toBe(true); expect(true).toBe(true);
}); });
}); });
@@ -408,14 +378,12 @@ describe("Rate Limiting", () => {
const unknownIP = "unknown"; const unknownIP = "unknown";
const email = `test-${Date.now()}@example.com`; const email = `test-${Date.now()}@example.com`;
// Should allow many login attempts in development with unknown IP
// (only email rate limit applies) // (only email rate limit applies)
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) { for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
const testEmail = `test-${Date.now()}-${i}@example.com`; const testEmail = `test-${Date.now()}-${i}@example.com`;
await rateLimitLogin(testEmail, unknownIP); 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); await rateLimitLogin(`final-${Date.now()}@example.com`, unknownIP);
}); });
@@ -423,12 +391,10 @@ describe("Rate Limiting", () => {
const unknownIP = "unknown"; const unknownIP = "unknown";
const email = `test-${Date.now()}@example.com`; const email = `test-${Date.now()}@example.com`;
// Use up email rate limit
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) { for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
await rateLimitLogin(email, unknownIP); await rateLimitLogin(email, unknownIP);
} }
// Next attempt should fail due to email limit
try { try {
await rateLimitLogin(email, unknownIP); await rateLimitLogin(email, unknownIP);
expect.unreachable("Should have thrown"); expect.unreachable("Should have thrown");
@@ -440,55 +406,46 @@ describe("Rate Limiting", () => {
it("should handle unknown IP in password reset", async () => { it("should handle unknown IP in password reset", async () => {
const unknownIP = "unknown"; const unknownIP = "unknown";
// In development, should allow many attempts (no IP limit)
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await rateLimitPasswordReset(unknownIP); await rateLimitPasswordReset(unknownIP);
} }
// Should not throw in development
expect(true).toBe(true); expect(true).toBe(true);
}); });
it("should handle unknown IP in registration", async () => { it("should handle unknown IP in registration", async () => {
const unknownIP = "unknown"; const unknownIP = "unknown";
// In development, should allow many attempts (no IP limit)
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await rateLimitRegistration(unknownIP); await rateLimitRegistration(unknownIP);
} }
// Should not throw in development
expect(true).toBe(true); expect(true).toBe(true);
}); });
it("should handle unknown IP in email verification", async () => { it("should handle unknown IP in email verification", async () => {
const unknownIP = "unknown"; const unknownIP = "unknown";
// In development, should allow many attempts (no IP limit)
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await rateLimitEmailVerification(unknownIP); await rateLimitEmailVerification(unknownIP);
} }
// Should not throw in development
expect(true).toBe(true); expect(true).toBe(true);
}); });
}); });
describe("Rate Limit Configuration", () => { describe("Rate Limit Configuration", () => {
it("should have reasonable limits configured", () => { it("should have reasonable limits configured", () => {
// Login should be more permissive than registration
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan( expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(
RATE_LIMITS.REGISTRATION_IP.maxAttempts RATE_LIMITS.REGISTRATION_IP.maxAttempts
); );
// All limits should be positive
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0); expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0);
expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0); expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0);
expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0); expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0);
expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0); expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0);
expect(RATE_LIMITS.EMAIL_VERIFICATION_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_IP.windowMs).toBeGreaterThanOrEqual(60000);
expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000); expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000);
expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual( expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual(
@@ -552,7 +509,6 @@ describe("Rate Limiting", () => {
const maxAttempts = 3; const maxAttempts = 3;
const windowMs = 60000; const windowMs = 60000;
// Exhaust the limit: 3 allowed, 4th blocked.
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
await checkRateLimit(id, maxAttempts, windowMs); await checkRateLimit(id, maxAttempts, windowMs);
} }
@@ -574,7 +530,6 @@ describe("Rate Limiting", () => {
const maxAttempts = 5; const maxAttempts = 5;
const windowMs = 60000; const windowMs = 60000;
// Instance A: 3 attempts.
clearRateLimitLocalCache(); clearRateLimitLocalCache();
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
await checkRateLimit(id, maxAttempts, windowMs); await checkRateLimit(id, maxAttempts, windowMs);
@@ -582,9 +537,9 @@ describe("Rate Limiting", () => {
// Instance B (fresh local cache) makes 2 more -> combined count = 5. // Instance B (fresh local cache) makes 2 more -> combined count = 5.
clearRateLimitLocalCache(); clearRateLimitLocalCache();
await checkRateLimit(id, maxAttempts, windowMs); // count 4 await checkRateLimit(id, maxAttempts, windowMs);
const remaining = await checkRateLimit(id, maxAttempts, windowMs); // count 5 const remaining = await checkRateLimit(id, maxAttempts, windowMs);
expect(remaining).toBe(0); // 5th allowed, no remaining expect(remaining).toBe(0);
// A 6th attempt from a fresh instance must be blocked — the shared store // A 6th attempt from a fresh instance must be blocked — the shared store
// aggregated the count across the two "instances". // aggregated the count across the two "instances".

View File

@@ -4,8 +4,6 @@
*/ */
import type { H3Event } from "vinxi/http"; import type { H3Event } from "vinxi/http";
import { SignJWT } from "jose";
import { env } from "~/env/server";
/** /**
* Create a mock H3Event for testing * Create a mock H3Event for testing
@@ -62,55 +60,6 @@ export function createMockEvent(options: {
return mockEvent; 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 * Common SQL injection payloads
*/ */
@@ -138,26 +87,6 @@ export const XSS_PAYLOADS = [
"<input onfocus=alert('XSS') autofocus>" "<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 * Measure execution time
*/ */
@@ -170,18 +99,6 @@ export async function measureTime<T>(
return { result, duration }; 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 * Generate random IP address
*/ */