Compare commits
2 Commits
33ca9213f2
...
e4a9be4899
| Author | SHA1 | Date | |
|---|---|---|---|
| e4a9be4899 | |||
| 898c891bd5 |
@@ -75,3 +75,11 @@ GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / g
|
|||||||
# Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token
|
# Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token
|
||||||
# or generate an internal auth token at: https://sentry.io/settings/account/api/keys/
|
# or generate an internal auth token at: https://sentry.io/settings/account/api/keys/
|
||||||
SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>"
|
SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>"
|
||||||
|
|
||||||
|
# ── The Nook licensing ──
|
||||||
|
NOOK_DB_URL="libsql://<nook-db>.turso.io"
|
||||||
|
NOOK_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||||
|
NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8 Ed25519 private key (scripts/generate-license-keys.ts)
|
||||||
|
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
|
||||||
|
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
||||||
|
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,3 +31,6 @@ perf-results-*.json
|
|||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
# pygienium run-state and check artifacts
|
||||||
|
.pygienium/
|
||||||
|
scripts/
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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, "-")
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 />}>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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(" ", "_"),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
160
src/db/create.ts
160
src/db/create.ts
@@ -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);
|
|
||||||
`
|
|
||||||
};
|
|
||||||
@@ -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
11
src/env/client.ts
vendored
@@ -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() === "";
|
||||||
|
|||||||
8
src/env/server.ts
vendored
8
src/env/server.ts
vendored
@@ -67,7 +67,13 @@ const serverEnvSchema = z.object({
|
|||||||
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
||||||
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
||||||
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
|
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
|
||||||
TURNSTILE_SECRET_KEY: z.string().min(1)
|
TURNSTILE_SECRET_KEY: z.string().min(1),
|
||||||
|
NOOK_DB_URL: z.string().min(1),
|
||||||
|
NOOK_DB_TOKEN: z.string().min(1),
|
||||||
|
NOOK_LICENSE_PRIVATE_KEY: z.string().min(1),
|
||||||
|
NOOK_STRIPE_SK: z.string().min(1),
|
||||||
|
NOOK_STRIPE_WEBHOOK_SECRET: z.string().min(1),
|
||||||
|
NOOK_STRIPE_PRICE_ID: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=/`;
|
|
||||||
}
|
|
||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from "./nav-config";
|
} from "./nav-config";
|
||||||
import { SITE_CONFIG, type SiteId } from "./site-context";
|
import { SITE_CONFIG, type SiteId } from "./site-context";
|
||||||
|
|
||||||
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
|
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
||||||
|
|
||||||
describe("NAV_CONFIG — per-site link sets", () => {
|
describe("NAV_CONFIG — per-site link sets", () => {
|
||||||
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
||||||
@@ -80,7 +80,7 @@ describe("NAV_CONFIG — href correctness", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("subdomain nav hrefs are public browser paths, never the internal rewritten prefix", () => {
|
it("subdomain nav hrefs are public browser paths, never the internal rewritten prefix", () => {
|
||||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
|
||||||
for (const item of NAV_CONFIG[id]) {
|
for (const item of NAV_CONFIG[id]) {
|
||||||
// No subdomain-prefixed paths leak into the public nav.
|
// No subdomain-prefixed paths leak into the public nav.
|
||||||
expect(item.href.startsWith(`/${id}/`)).toBe(false);
|
expect(item.href.startsWith(`/${id}/`)).toBe(false);
|
||||||
@@ -111,7 +111,7 @@ describe("NAV_CONFIG — href correctness", () => {
|
|||||||
|
|
||||||
describe("NAV_CONFIG — auth-scoping by construction", () => {
|
describe("NAV_CONFIG — auth-scoping by construction", () => {
|
||||||
it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => {
|
it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => {
|
||||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
|
||||||
for (const item of NAV_CONFIG[id]) {
|
for (const item of NAV_CONFIG[id]) {
|
||||||
expect(item.showLoggedIn).toBeUndefined();
|
expect(item.showLoggedIn).toBeUndefined();
|
||||||
expect(item.showLoggedOut).toBeUndefined();
|
expect(item.showLoggedOut).toBeUndefined();
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
|||||||
{ label: "Contact", href: "/contact", icon: "contact" },
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
{ label: "Privacy", href: "/privacy", icon: "privacy" },
|
{ label: "Privacy", href: "/privacy", icon: "privacy" },
|
||||||
{ label: "Downloads", href: "/downloads", icon: "downloads" }
|
{ label: "Downloads", href: "/downloads", icon: "downloads" }
|
||||||
|
],
|
||||||
|
nook: [
|
||||||
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
|
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ describe("resolveSiteFromHost", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
|
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
|
||||||
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
|
||||||
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
|
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
|
||||||
expect(SITE_CONFIG[id].subdomain).toBe(id);
|
expect(SITE_CONFIG[id].subdomain).toBe(id);
|
||||||
expect(SITE_CONFIG[id].titleSuffix).toBe(
|
expect(SITE_CONFIG[id].titleSuffix).toBe(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
|
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo";
|
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo" | "nook";
|
||||||
|
|
||||||
export interface Site {
|
export interface Site {
|
||||||
/** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */
|
/** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */
|
||||||
@@ -122,6 +122,18 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
|||||||
brandColor: "#41a5ff",
|
brandColor: "#41a5ff",
|
||||||
ogDefaultImage: "/inputhalo/og-default.png",
|
ogDefaultImage: "/inputhalo/og-default.png",
|
||||||
faviconPath: "/inputhalo/favicon/favicon.ico"
|
faviconPath: "/inputhalo/favicon/favicon.ico"
|
||||||
|
},
|
||||||
|
nook: {
|
||||||
|
id: "nook",
|
||||||
|
subdomain: "nook",
|
||||||
|
domain: `nook.${BASE_DOMAIN}`,
|
||||||
|
baseRoutePrefix: "/nook",
|
||||||
|
displayName: "The Nook",
|
||||||
|
titleSuffix: " | The Nook",
|
||||||
|
brandColor: "#8b5cf6",
|
||||||
|
brandColorDark: "#a78bfa",
|
||||||
|
ogDefaultImage: "/nook/og-default.png",
|
||||||
|
faviconPath: "/nook/favicon/favicon.ico"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,7 +142,8 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
|
|||||||
SITE_CONFIG.nessa,
|
SITE_CONFIG.nessa,
|
||||||
SITE_CONFIG.lineage,
|
SITE_CONFIG.lineage,
|
||||||
SITE_CONFIG.gaze,
|
SITE_CONFIG.gaze,
|
||||||
SITE_CONFIG.inputhalo
|
SITE_CONFIG.inputhalo,
|
||||||
|
SITE_CONFIG.nook
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
|
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -152,7 +146,7 @@ describe("SITEMAP_ROUTES validation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("each site has at least the home page entry", () => {
|
it("each site has at least the home page entry", () => {
|
||||||
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
|
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
||||||
for (const id of siteIds) {
|
for (const id of siteIds) {
|
||||||
expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true);
|
expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,11 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
|||||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||||
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
||||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
||||||
|
],
|
||||||
|
|
||||||
|
nook: [
|
||||||
|
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||||
|
{ path: "/checkout", changefreq: "monthly", priority: 0.5 },
|
||||||
|
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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, {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
61
src/routes/api/TheNook/appcast.xml.ts
Normal file
61
src/routes/api/TheNook/appcast.xml.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { env } from "~/env/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the TheNook appcast.xml file from S3
|
||||||
|
* This endpoint is used by Sparkle updater to check for new versions
|
||||||
|
*
|
||||||
|
* URL: https://freno.me/api/TheNook/appcast.xml
|
||||||
|
*/
|
||||||
|
export async function GET(_event: APIEvent) {
|
||||||
|
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||||
|
const key = "api/TheNook/appcast.xml";
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new S3Client({
|
||||||
|
region: env.AWS_REGION,
|
||||||
|
credentials: credentials
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.send(command);
|
||||||
|
|
||||||
|
if (!response.Body) {
|
||||||
|
return new Response("Appcast not found", {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.Body.transformToString();
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=300", // Cache for 5 minutes
|
||||||
|
"Access-Control-Allow-Origin": "*" // Allow CORS for Sparkle appcast
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch appcast:", error);
|
||||||
|
return new Response("Internal Server Error", {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ export async function GET(event: APIEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate filename format (only allow Gaze or InputHalo files)
|
const validPrefixes = ["Gaze", "InputHalo", "TheNook"];
|
||||||
const validPrefixes = ["Gaze", "InputHalo"];
|
|
||||||
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
||||||
if (
|
if (
|
||||||
!isValidPrefix ||
|
!isValidPrefix ||
|
||||||
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
|
(!filename.endsWith(".dmg") &&
|
||||||
|
!filename.endsWith(".delta") &&
|
||||||
|
!filename.endsWith(".zip"))
|
||||||
) {
|
) {
|
||||||
return new Response("Invalid file format", {
|
return new Response("Invalid file format", {
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -70,12 +71,12 @@ export async function GET(event: APIEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get content type based on file extension
|
const contentType = filename.endsWith(".zip")
|
||||||
const contentType = filename.endsWith(".dmg")
|
? "application/zip"
|
||||||
? "application/x-apple-diskimage"
|
: filename.endsWith(".dmg")
|
||||||
: "application/octet-stream";
|
? "application/x-apple-diskimage"
|
||||||
|
: "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 +94,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,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|||||||
21
src/routes/api/the-nook/_lib.ts
Normal file
21
src/routes/api/the-nook/_lib.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Shared JSON response helpers for the The Nook license API routes.
|
||||||
|
* Underscore-prefixed file — not a route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function json(data: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function error(message: string, status: number): Response {
|
||||||
|
return json({ error: message }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function isUuid(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && UUID_RE.test(value);
|
||||||
|
}
|
||||||
97
src/routes/api/the-nook/activate.ts
Normal file
97
src/routes/api/the-nook/activate.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Client } from "@libsql/client/web";
|
||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { nookSchemaBootstrap, verifyLicenseKey } from "~/server/nook";
|
||||||
|
import { json, error, isUuid } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/activate
|
||||||
|
* Body: { key, deviceFingerprint, deviceName }
|
||||||
|
*
|
||||||
|
* Re-verifies the Ed25519 license signature server-side (defense in depth —
|
||||||
|
* the app already verified it offline), enforces the 3-device activation cap,
|
||||||
|
* and records/refreshes an activation row. Re-activating an already-activated
|
||||||
|
* fingerprint is idempotent and does NOT consume an extra seat.
|
||||||
|
*/
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return error("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
const b = (body ?? {}) as Record<string, unknown>;
|
||||||
|
const key = typeof b.key === "string" ? b.key : "";
|
||||||
|
const fingerprint = b.deviceFingerprint;
|
||||||
|
const deviceName =
|
||||||
|
typeof b.deviceName === "string" && b.deviceName.length > 0
|
||||||
|
? b.deviceName
|
||||||
|
: "Mac";
|
||||||
|
|
||||||
|
if (!key || !isUuid(fingerprint)) {
|
||||||
|
return error("Invalid request", 400);
|
||||||
|
}
|
||||||
|
if (!verifyLicenseKey(key)) {
|
||||||
|
return error("Invalid license key", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
|
||||||
|
const licenseRes = await conn.execute({
|
||||||
|
sql: "SELECT id, email, revoked FROM licenses WHERE key = ?",
|
||||||
|
args: [key]
|
||||||
|
});
|
||||||
|
if (licenseRes.rows.length === 0) {
|
||||||
|
return error("License not found", 404);
|
||||||
|
}
|
||||||
|
const license = licenseRes.rows[0] as { id: string; email: string; revoked: number };
|
||||||
|
if (license.revoked === 1) {
|
||||||
|
return error("License revoked", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent re-activation: already-active fingerprint does not consume a seat.
|
||||||
|
const existingRes = await conn.execute({
|
||||||
|
sql: `SELECT id FROM activations
|
||||||
|
WHERE license_id = ? AND device_fingerprint = ? AND deactivated_at IS NULL`,
|
||||||
|
args: [license.id, fingerprint]
|
||||||
|
});
|
||||||
|
if (existingRes.rows.length > 0) {
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE activations SET activated_at = ? WHERE id = ?",
|
||||||
|
args: [new Date().toISOString(), existingRes.rows[0] as { id: string }]
|
||||||
|
});
|
||||||
|
const count = await activeCount(conn, license.id);
|
||||||
|
return json({ ok: true, email: license.email, activatedCount: count });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await activeCount(conn, license.id);
|
||||||
|
if (count >= 3) {
|
||||||
|
return error("Activation limit reached (3 devices)", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO activations
|
||||||
|
(id, license_id, device_fingerprint, device_name, activated_at, deactivated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NULL)`,
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
license.id,
|
||||||
|
fingerprint,
|
||||||
|
deviceName,
|
||||||
|
new Date().toISOString()
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return json({ ok: true, email: license.email, activatedCount: count + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activeCount(
|
||||||
|
conn: Client,
|
||||||
|
licenseId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const res = await conn.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS n FROM activations WHERE license_id = ? AND deactivated_at IS NULL",
|
||||||
|
args: [licenseId]
|
||||||
|
});
|
||||||
|
return Number((res.rows[0] as { n: number | bigint }).n);
|
||||||
|
}
|
||||||
32
src/routes/api/the-nook/by-session.ts
Normal file
32
src/routes/api/the-nook/by-session.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { nookSchemaBootstrap } from "~/server/nook";
|
||||||
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/the-nook/by-session?session_id=cs_...
|
||||||
|
*
|
||||||
|
* Used by the success page (nook.freno.me/success) to retrieve the license
|
||||||
|
* key once the `checkout.session.completed` webhook has landed. The page
|
||||||
|
* polls this endpoint until the license row exists.
|
||||||
|
*/
|
||||||
|
export async function GET(event: APIEvent) {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
const sessionId = url.searchParams.get("session_id");
|
||||||
|
if (!sessionId || !sessionId.startsWith("cs_")) {
|
||||||
|
return error("Invalid session id", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
|
||||||
|
const res = await conn.execute({
|
||||||
|
sql: "SELECT key, email FROM licenses WHERE stripe_session_id = ?",
|
||||||
|
args: [sessionId]
|
||||||
|
});
|
||||||
|
if (res.rows.length === 0) {
|
||||||
|
return error("Not found", 404);
|
||||||
|
}
|
||||||
|
const license = res.rows[0] as { key: string; email: string };
|
||||||
|
return json({ key: license.key, email: license.email });
|
||||||
|
}
|
||||||
71
src/routes/api/the-nook/checkout.ts
Normal file
71
src/routes/api/the-nook/checkout.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { env } from "~/env/server";
|
||||||
|
import { TURNSTILE_CONFIG } from "~/config";
|
||||||
|
import { verifyTurnstileToken } from "~/server/fetch-utils";
|
||||||
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/checkout
|
||||||
|
* Body: { turnstileToken }
|
||||||
|
*
|
||||||
|
* Verifies the Cloudflare Turnstile token, then creates a Stripe Checkout
|
||||||
|
* session for the one-time $10 The Nook license. Returns the hosted
|
||||||
|
* checkout URL for the client to redirect to.
|
||||||
|
*
|
||||||
|
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
|
||||||
|
* `stripe` npm dependency added.
|
||||||
|
*/
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return error("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
const b = (body ?? {}) as Record<string, unknown>;
|
||||||
|
const turnstileToken = typeof b.turnstileToken === "string" ? b.turnstileToken : "";
|
||||||
|
|
||||||
|
const turnstileValid = await verifyTurnstileToken(
|
||||||
|
turnstileToken,
|
||||||
|
env.TURNSTILE_SECRET_KEY,
|
||||||
|
TURNSTILE_CONFIG.VERIFY_URL,
|
||||||
|
TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
if (!turnstileValid) {
|
||||||
|
return error("Security verification failed", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
mode: "payment",
|
||||||
|
"line_items[0][price]": env.NOOK_STRIPE_PRICE_ID,
|
||||||
|
"line_items[0][quantity]": "1",
|
||||||
|
success_url: "https://nook.freno.me/success?session_id={CHECKOUT_SESSION_ID}",
|
||||||
|
cancel_url: "https://nook.freno.me/checkout"
|
||||||
|
});
|
||||||
|
|
||||||
|
let stripeRes: Response;
|
||||||
|
try {
|
||||||
|
stripeRes = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.NOOK_STRIPE_SK}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
},
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return error("Stripe unreachable", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stripeRes.ok) {
|
||||||
|
const stripeError = await stripeRes.text();
|
||||||
|
console.error("Stripe checkout error:", stripeError);
|
||||||
|
return error(`Stripe error: ${stripeError}`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await stripeRes.json()) as { url?: string };
|
||||||
|
if (!data.url) {
|
||||||
|
return error("Stripe returned no checkout URL", 502);
|
||||||
|
}
|
||||||
|
return json({ checkoutUrl: data.url });
|
||||||
|
}
|
||||||
58
src/routes/api/the-nook/deactivate.ts
Normal file
58
src/routes/api/the-nook/deactivate.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { nookSchemaBootstrap, verifyLicenseKey } from "~/server/nook";
|
||||||
|
import { json, error, isUuid } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/deactivate
|
||||||
|
* Body: { key, deviceFingerprint }
|
||||||
|
*
|
||||||
|
* Marks the matching activation as deactivated, freeing a device seat.
|
||||||
|
* - No activation row for this (license, fingerprint): 404.
|
||||||
|
* - Already deactivated: idempotent success (does not error).
|
||||||
|
*/
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return error("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
const b = (body ?? {}) as Record<string, unknown>;
|
||||||
|
const key = typeof b.key === "string" ? b.key : "";
|
||||||
|
const fingerprint = b.deviceFingerprint;
|
||||||
|
|
||||||
|
if (!key || !isUuid(fingerprint)) {
|
||||||
|
return error("Invalid request", 400);
|
||||||
|
}
|
||||||
|
if (!verifyLicenseKey(key)) {
|
||||||
|
return error("Invalid license key", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
|
||||||
|
const licenseRes = await conn.execute({
|
||||||
|
sql: "SELECT id FROM licenses WHERE key = ?",
|
||||||
|
args: [key]
|
||||||
|
});
|
||||||
|
if (licenseRes.rows.length === 0) {
|
||||||
|
return error("License not found", 404);
|
||||||
|
}
|
||||||
|
const license = licenseRes.rows[0] as { id: string };
|
||||||
|
|
||||||
|
const activationRes = await conn.execute({
|
||||||
|
sql: "SELECT id FROM activations WHERE license_id = ? AND device_fingerprint = ?",
|
||||||
|
args: [license.id, fingerprint]
|
||||||
|
});
|
||||||
|
if (activationRes.rows.length === 0) {
|
||||||
|
return error("No activation found for this device", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE activations SET deactivated_at = ? WHERE id = ?",
|
||||||
|
args: [new Date().toISOString(), (activationRes.rows[0] as { id: string }).id]
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
51
src/routes/api/the-nook/status.ts
Normal file
51
src/routes/api/the-nook/status.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { nookSchemaBootstrap, verifyLicenseKey } from "~/server/nook";
|
||||||
|
import { json, error, isUuid } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/status
|
||||||
|
* Body: { key, deviceFingerprint }
|
||||||
|
*
|
||||||
|
* POST (not GET) because the license key is sensitive in URLs/logs.
|
||||||
|
* Returns the license state: "valid" | "revoked" | "unknown_key" with the
|
||||||
|
* current active activation count (for the 3-seat cap display).
|
||||||
|
*/
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return error("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
const b = (body ?? {}) as Record<string, unknown>;
|
||||||
|
const key = typeof b.key === "string" ? b.key : "";
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return error("Invalid request", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
|
||||||
|
const licenseRes = await conn.execute({
|
||||||
|
sql: "SELECT id, revoked FROM licenses WHERE key = ?",
|
||||||
|
args: [key]
|
||||||
|
});
|
||||||
|
if (licenseRes.rows.length === 0) {
|
||||||
|
return json({ state: "unknown_key", activatedCount: 0 });
|
||||||
|
}
|
||||||
|
const license = licenseRes.rows[0] as { id: string; revoked: number };
|
||||||
|
|
||||||
|
if (license.revoked === 1) {
|
||||||
|
return json({ state: "revoked", activatedCount: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const countRes = await conn.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS n FROM activations WHERE license_id = ? AND deactivated_at IS NULL",
|
||||||
|
args: [license.id]
|
||||||
|
});
|
||||||
|
const activatedCount = Number((countRes.rows[0] as { n: number | bigint }).n);
|
||||||
|
|
||||||
|
return json({ state: "valid", activatedCount });
|
||||||
|
}
|
||||||
45
src/routes/api/the-nook/trial.ts
Normal file
45
src/routes/api/the-nook/trial.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { nookSchemaBootstrap } from "~/server/nook";
|
||||||
|
import { json, error, isUuid } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/trial
|
||||||
|
* Body: { fingerprint, deviceName }
|
||||||
|
*
|
||||||
|
* Returns the canonical trial start date. If a `trials` row already exists
|
||||||
|
* for the fingerprint it returns the STORED value (the anti-reset point) —
|
||||||
|
* a wiped-Keychain reinstall cannot push the trial start forward.
|
||||||
|
*/
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return error("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
const b = (body ?? {}) as Record<string, unknown>;
|
||||||
|
const fingerprint = b.fingerprint;
|
||||||
|
if (!isUuid(fingerprint)) {
|
||||||
|
return error("Invalid fingerprint", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
|
||||||
|
const existing = await conn.execute({
|
||||||
|
sql: "SELECT started_at FROM trials WHERE fingerprint = ?",
|
||||||
|
args: [fingerprint]
|
||||||
|
});
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
const trialStart = existing.rows[0] as { started_at: string };
|
||||||
|
return json({ fingerprint, trialStart: trialStart.started_at, trialDays: 14 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const trialStart = new Date().toISOString();
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)",
|
||||||
|
args: [fingerprint, trialStart]
|
||||||
|
});
|
||||||
|
return json({ fingerprint, trialStart, trialDays: 14 });
|
||||||
|
}
|
||||||
126
src/routes/api/the-nook/webhooks/stripe.ts
Normal file
126
src/routes/api/the-nook/webhooks/stripe.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { env } from "~/env/server";
|
||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { nookSchemaBootstrap, issueLicense } from "~/server/nook";
|
||||||
|
import { json } from "../_lib";
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/webhooks/stripe
|
||||||
|
*
|
||||||
|
* Verifies the Stripe webhook signature manually (no `stripe` npm package),
|
||||||
|
* handles `checkout.session.completed`: issues a license row (idempotent via
|
||||||
|
* the UNIQUE `stripe_session_id`) and emails the key to the buyer.
|
||||||
|
*
|
||||||
|
* Email failure is a logged warning, never a purchase failure — the license
|
||||||
|
* row is written before emailing, and the success page can still fetch the
|
||||||
|
* key via by-session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface StripeSessionCompleted {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
customer_details?: { email?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean {
|
||||||
|
const params = new Map<string, string>();
|
||||||
|
for (const pair of signatureHeader.split(",")) {
|
||||||
|
const eq = pair.indexOf("=");
|
||||||
|
if (eq > 0) params.set(pair.slice(0, eq), pair.slice(eq + 1));
|
||||||
|
}
|
||||||
|
const t = params.get("t");
|
||||||
|
const v1 = params.get("v1");
|
||||||
|
if (!t || !v1) return false;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const ts = Number(t);
|
||||||
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) return false;
|
||||||
|
|
||||||
|
const expected = createHmac("sha256", env.NOOK_STRIPE_WEBHOOK_SECRET)
|
||||||
|
.update(`${t}.${rawBody}`)
|
||||||
|
.digest("hex");
|
||||||
|
const provided = Buffer.from(v1, "hex");
|
||||||
|
const expectedBuffer = Buffer.from(expected, "hex");
|
||||||
|
if (provided.length !== expectedBuffer.length) return false;
|
||||||
|
return timingSafeEqual(provided, expectedBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailLicenseKey(to: string, licenseKey: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch("https://api.brevo.com/v3/smtp/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"api-key": env.SENDINBLUE_KEY,
|
||||||
|
"content-type": "application/json",
|
||||||
|
accept: "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sender: { email: env.EMAIL_FROM },
|
||||||
|
to: [{ email: to }],
|
||||||
|
subject: "Your The Nook license key",
|
||||||
|
textContent:
|
||||||
|
`Your The Nook license key is:\n\n${licenseKey}\n\n` +
|
||||||
|
`Open The Nook, go to Settings, and enter this key to activate.\n` +
|
||||||
|
`You can activate up to 3 devices.\n\n— The Nook`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Purchase must not fail because email delivery did.
|
||||||
|
console.error("Failed to email The Nook license key:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
const rawBody = await event.request.text();
|
||||||
|
const signatureHeader = event.request.headers.get("Stripe-Signature");
|
||||||
|
if (!signatureHeader) {
|
||||||
|
return json({ error: "Missing signature" }, 400);
|
||||||
|
}
|
||||||
|
if (!verifyStripeSignature(rawBody, signatureHeader)) {
|
||||||
|
return json({ error: "Invalid signature" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return json({ error: "Invalid JSON" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = payload as StripeSessionCompleted;
|
||||||
|
if (body.type !== "checkout.session.completed") {
|
||||||
|
// Unknown event types are acknowledged, not retried.
|
||||||
|
return json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = body.id;
|
||||||
|
const email = body.customer_details?.email;
|
||||||
|
if (!sessionId || !email) {
|
||||||
|
console.error("checkout.session.completed missing id or email:", rawBody);
|
||||||
|
return json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
|
||||||
|
// Idempotency: a session that already produced a license is acknowledged
|
||||||
|
// without re-issuing or re-emailing.
|
||||||
|
const existing = await conn.execute({
|
||||||
|
sql: "SELECT id FROM licenses WHERE stripe_session_id = ?",
|
||||||
|
args: [sessionId]
|
||||||
|
});
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
return json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { key } = await issueLicense(email, sessionId);
|
||||||
|
await emailLicenseKey(email, key);
|
||||||
|
} catch (error) {
|
||||||
|
// UNIQUE stripe_session_id conflict from a racing duplicate delivery.
|
||||||
|
console.error("Failed to issue The Nook license (webhook):", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ received: true });
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,6 +147,180 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isRateLimited = (errorCode: string | undefined, message: string) =>
|
||||||
|
errorCode === "TOO_MANY_REQUESTS" || message.includes("Too many attempts");
|
||||||
|
|
||||||
|
const submitRegister = async () => {
|
||||||
|
if (!emailRef || !passwordRef || !passwordConfRef) {
|
||||||
|
setError("Please fill in all fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = emailRef.value;
|
||||||
|
const password = passwordRef.value;
|
||||||
|
const passwordConf = passwordConfRef.value;
|
||||||
|
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
setError("Invalid email address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordValidation = validatePassword(password);
|
||||||
|
if (!passwordValidation.isValid) {
|
||||||
|
setError(passwordValidation.errors[0] || "Invalid password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== passwordConf) {
|
||||||
|
setError("passwordMismatch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/trpc/auth.emailRegistration", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
passwordConfirmation: passwordConf
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.result?.data) {
|
||||||
|
navigate("/account", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMsg =
|
||||||
|
result.error?.message ||
|
||||||
|
result.result?.data?.message ||
|
||||||
|
"Registration failed";
|
||||||
|
const errorCode = result.error?.data?.code;
|
||||||
|
|
||||||
|
if (isRateLimited(errorCode, errorMsg)) {
|
||||||
|
setError(errorMsg);
|
||||||
|
} else if (
|
||||||
|
errorMsg.includes("duplicate") ||
|
||||||
|
errorMsg.includes("already exists")
|
||||||
|
) {
|
||||||
|
if (errorMsg.includes("sign in and add a password")) {
|
||||||
|
setError("provider_exists");
|
||||||
|
} else {
|
||||||
|
setError("duplicate");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPasswordLogin = async () => {
|
||||||
|
if (!emailRef || !passwordRef || !rememberMeRef) {
|
||||||
|
setError("Please fill in all fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = emailRef.value;
|
||||||
|
const password = passwordRef.value;
|
||||||
|
const rememberMe = rememberMeRef.checked;
|
||||||
|
|
||||||
|
const response = await fetch("/api/trpc/auth.emailPasswordLogin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, rememberMe })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.result?.data?.success) {
|
||||||
|
setShowPasswordSuccess(true);
|
||||||
|
revalidateAuth(); // Refresh auth state globally
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate("/account", { replace: true });
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = result.error?.message || "";
|
||||||
|
const errorCode = result.error?.data?.code;
|
||||||
|
|
||||||
|
if (isRateLimited(errorCode, errorMessage)) {
|
||||||
|
setError(errorMessage);
|
||||||
|
} else if (
|
||||||
|
errorCode === "FORBIDDEN" ||
|
||||||
|
errorMessage.includes("Account locked") ||
|
||||||
|
errorMessage.includes("Account is locked")
|
||||||
|
) {
|
||||||
|
setError(errorMessage);
|
||||||
|
} else {
|
||||||
|
setShowPasswordError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEmailLink = async () => {
|
||||||
|
if (!emailRef || !rememberMeRef) {
|
||||||
|
setError("Please enter your email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = emailRef.value;
|
||||||
|
const rememberMe = rememberMeRef.checked;
|
||||||
|
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
setError("Invalid email address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, rememberMe })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.result?.data?.success) {
|
||||||
|
setEmailSent(true);
|
||||||
|
|
||||||
|
// Set countdown directly - cookie might not be readable immediately
|
||||||
|
const expirationTime = new Date(
|
||||||
|
Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS
|
||||||
|
);
|
||||||
|
startCountdown(expirationTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMsg =
|
||||||
|
result.error?.message ||
|
||||||
|
result.result?.data?.message ||
|
||||||
|
"Failed to send email";
|
||||||
|
const errorCode = result.error?.data?.code;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isRateLimited(errorCode, errorMsg) ||
|
||||||
|
errorMsg.includes("countdown not expired")
|
||||||
|
) {
|
||||||
|
setError(
|
||||||
|
errorMsg.includes("countdown")
|
||||||
|
? "Please wait before requesting another email link"
|
||||||
|
: errorMsg
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start the countdown timer when rate limited
|
||||||
|
const timer = getClientCookie("emailLoginLinkRequested");
|
||||||
|
if (timer) {
|
||||||
|
try {
|
||||||
|
startCountdown(timer);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to start countdown from cookie:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formHandler = async (e: Event) => {
|
const formHandler = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -158,181 +330,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (register()) {
|
if (register()) {
|
||||||
if (!emailRef || !passwordRef || !passwordConfRef) {
|
await submitRegister();
|
||||||
setError("Please fill in all fields");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = emailRef.value;
|
|
||||||
const password = passwordRef.value;
|
|
||||||
const passwordConf = passwordConfRef.value;
|
|
||||||
|
|
||||||
if (!isValidEmail(email)) {
|
|
||||||
setError("Invalid email address");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordValidation = validatePassword(password);
|
|
||||||
if (!passwordValidation.isValid) {
|
|
||||||
setError(passwordValidation.errors[0] || "Invalid password");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== passwordConf) {
|
|
||||||
setError("passwordMismatch");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch("/api/trpc/auth.emailRegistration", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
passwordConfirmation: passwordConf
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.result?.data) {
|
|
||||||
navigate("/account", { replace: true });
|
|
||||||
} else {
|
|
||||||
const errorMsg =
|
|
||||||
result.error?.message ||
|
|
||||||
result.result?.data?.message ||
|
|
||||||
"Registration failed";
|
|
||||||
const errorCode = result.error?.data?.code;
|
|
||||||
|
|
||||||
if (
|
|
||||||
errorCode === "TOO_MANY_REQUESTS" ||
|
|
||||||
errorMsg.includes("Too many attempts")
|
|
||||||
) {
|
|
||||||
setError(errorMsg);
|
|
||||||
} else if (
|
|
||||||
errorMsg.includes("duplicate") ||
|
|
||||||
errorMsg.includes("already exists")
|
|
||||||
) {
|
|
||||||
if (errorMsg.includes("sign in and add a password")) {
|
|
||||||
setError("provider_exists");
|
|
||||||
} else {
|
|
||||||
setError("duplicate");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(errorMsg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (usePassword()) {
|
} else if (usePassword()) {
|
||||||
if (!emailRef || !passwordRef || !rememberMeRef) {
|
await submitPasswordLogin();
|
||||||
setError("Please fill in all fields");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = emailRef.value;
|
|
||||||
const password = passwordRef.value;
|
|
||||||
const rememberMe = rememberMeRef.checked;
|
|
||||||
|
|
||||||
const response = await fetch("/api/trpc/auth.emailPasswordLogin", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email, password, rememberMe })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.result?.data?.success) {
|
|
||||||
setShowPasswordSuccess(true);
|
|
||||||
revalidateAuth(); // Refresh auth state globally
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate("/account", { replace: true });
|
|
||||||
}, 500);
|
|
||||||
} else {
|
|
||||||
const errorMessage = result.error?.message || "";
|
|
||||||
const errorCode = result.error?.data?.code;
|
|
||||||
|
|
||||||
if (
|
|
||||||
errorCode === "TOO_MANY_REQUESTS" ||
|
|
||||||
errorMessage.includes("Too many attempts")
|
|
||||||
) {
|
|
||||||
setError(errorMessage);
|
|
||||||
} else if (
|
|
||||||
errorCode === "FORBIDDEN" ||
|
|
||||||
errorMessage.includes("Account locked") ||
|
|
||||||
errorMessage.includes("Account is locked")
|
|
||||||
) {
|
|
||||||
setError(errorMessage);
|
|
||||||
} else {
|
|
||||||
setShowPasswordError(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (!emailRef || !rememberMeRef) {
|
await submitEmailLink();
|
||||||
setError("Please enter your email");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = emailRef.value;
|
|
||||||
const rememberMe = rememberMeRef.checked;
|
|
||||||
|
|
||||||
if (!isValidEmail(email)) {
|
|
||||||
setError("Invalid email address");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email, rememberMe })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.result?.data?.success) {
|
|
||||||
setEmailSent(true);
|
|
||||||
|
|
||||||
// Set countdown directly - cookie might not be readable immediately
|
|
||||||
const expirationTime = new Date(
|
|
||||||
Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS
|
|
||||||
);
|
|
||||||
startCountdown(expirationTime);
|
|
||||||
} else {
|
|
||||||
const errorMsg =
|
|
||||||
result.error?.message ||
|
|
||||||
result.result?.data?.message ||
|
|
||||||
"Failed to send email";
|
|
||||||
const errorCode = result.error?.data?.code;
|
|
||||||
|
|
||||||
if (
|
|
||||||
errorCode === "TOO_MANY_REQUESTS" ||
|
|
||||||
errorMsg.includes("countdown not expired") ||
|
|
||||||
errorMsg.includes("Too many attempts")
|
|
||||||
) {
|
|
||||||
setError(
|
|
||||||
errorMsg.includes("countdown")
|
|
||||||
? "Please wait before requesting another email link"
|
|
||||||
: errorMsg
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start the countdown timer when rate limited
|
|
||||||
const timer = getClientCookie("emailLoginLinkRequested");
|
|
||||||
if (timer) {
|
|
||||||
try {
|
|
||||||
startCountdown(timer);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to start countdown from cookie:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(errorMsg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
|
|||||||
110
src/routes/nook/checkout.tsx
Normal file
110
src/routes/nook/checkout.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { createSignal, onMount } from "solid-js";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import Button from "~/components/ui/Button";
|
||||||
|
import { env } from "~/env/client";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
|
|
||||||
|
export default function NookCheckout() {
|
||||||
|
const site = useSite();
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const [loading, setLoading] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal("");
|
||||||
|
const [turnstileToken, setTurnstileToken] = createSignal("");
|
||||||
|
|
||||||
|
const brandColor = () =>
|
||||||
|
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => {
|
||||||
|
const container = document.getElementById("turnstile-widget-nook");
|
||||||
|
if (container && (window as any).turnstile) {
|
||||||
|
(window as any).turnstile.render(container, {
|
||||||
|
sitekey: env.VITE_TURNSTILE_SITE_KEY,
|
||||||
|
theme: isDark() ? "dark" : "light",
|
||||||
|
callback: (token: string) => setTurnstileToken(token),
|
||||||
|
"expired-callback": () => setTurnstileToken("")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
const buy = async () => {
|
||||||
|
if (loading()) return;
|
||||||
|
if (!turnstileToken()) {
|
||||||
|
setError("Complete the security check to continue.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/the-nook/checkout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ turnstileToken: turnstileToken() })
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { checkoutUrl?: string; error?: string };
|
||||||
|
if (!res.ok || !data.checkoutUrl) {
|
||||||
|
setError(data.error ?? "Unable to start checkout. Please try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = data.checkoutUrl;
|
||||||
|
} catch {
|
||||||
|
setError("Unable to start checkout. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title="Checkout"
|
||||||
|
description="Buy a The Nook license — $10 one-time for up to 3 devices."
|
||||||
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
|
|
||||||
|
<div class="bg-base mx-auto max-w-lg px-4 py-16">
|
||||||
|
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
|
||||||
|
<h1 class="text-text mb-1 text-3xl font-bold">The Nook</h1>
|
||||||
|
<p class="text-subtext0 mb-6">Lifetime License — 3 devices</p>
|
||||||
|
|
||||||
|
<div class="text-text mb-6 text-4xl font-extrabold">
|
||||||
|
$10<span class="text-subtext0 text-base font-normal"> one-time</span>
|
||||||
|
</div>
|
||||||
|
<ul class="text-subtext0 mb-6 space-y-2 text-sm">
|
||||||
|
<li>• Activate on up to 3 of your own Macs</li>
|
||||||
|
<li>• No subscription, ever</li>
|
||||||
|
<li>• Key delivered by email + on this page</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div id="turnstile-widget-nook" class="mb-4" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="lg"
|
||||||
|
color={brandColor()}
|
||||||
|
fullWidth
|
||||||
|
loading={loading()}
|
||||||
|
onClick={buy}
|
||||||
|
>
|
||||||
|
Pay $10
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{error() && (
|
||||||
|
<p class="text-red mt-4 text-sm">{error()}</p>
|
||||||
|
)}
|
||||||
|
<p class="text-subtext1 mt-4 text-xs">
|
||||||
|
Billed once through Stripe. License delivered immediately after payment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/routes/nook/index.tsx
Normal file
147
src/routes/nook/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import Button from "~/components/ui/Button";
|
||||||
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
|
||||||
|
const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip";
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
title: "Unified device control",
|
||||||
|
body: "Monitor and control the hardware and services behind your Mac from one fast, native panel."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fan & thermal insight",
|
||||||
|
body: "Read and control system fans, watch temperatures, and keep performance predictable under load."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Private by design",
|
||||||
|
body: "Runs fully on your machine with no mandatory accounts. Your hardware data never leaves the device."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "One-time license",
|
||||||
|
body: "Pay once, activate on up to three of your own Macs. No subscriptions, no forced renewals."
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function NookLanding() {
|
||||||
|
const site = useSite();
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const brandColor = () =>
|
||||||
|
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title="Home"
|
||||||
|
description="The Nook — a native macOS utility for hardware control, fan and thermal insight, and quiet system services."
|
||||||
|
ogImage="/nook/og-default.png"
|
||||||
|
ogTitle="The Nook — native macOS hardware control"
|
||||||
|
ogDescription="A one-time-purchase macOS app for fan control, thermal insight, and quiet system services."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubdomainHeader />
|
||||||
|
|
||||||
|
{/* ── Hero ─────────────────────────────────────────────────────── */}
|
||||||
|
<div class="relative flex min-h-screen flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-0"
|
||||||
|
style={{
|
||||||
|
background: isDark()
|
||||||
|
? "radial-gradient(ellipse at top, #241a38 0%, #0b0b10 70%)"
|
||||||
|
: "radial-gradient(ellipse at top, #efe9ff 0%, #f5f5f5 70%)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 py-24 text-center">
|
||||||
|
<div class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm"
|
||||||
|
style={{ border: "1px solid var(--color-overlay0)", background: "var(--color-surface0)" }}>
|
||||||
|
The Nook
|
||||||
|
</div>
|
||||||
|
<h1 class="text-text mb-4 text-5xl font-bold tracking-tight">
|
||||||
|
Your Mac, under your control
|
||||||
|
</h1>
|
||||||
|
<p class="text-subtext0 mb-2 max-w-xl text-xl">
|
||||||
|
Fan control, thermal insight, and quiet system services — native macOS, one-time license.
|
||||||
|
</p>
|
||||||
|
<p class="text-subtext1 mb-8 text-sm">
|
||||||
|
macOS 14+ · 14-day free trial · 3 devices
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="lg"
|
||||||
|
color={brandColor()}
|
||||||
|
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
|
||||||
|
>
|
||||||
|
Download trial
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => (window.location.href = "/checkout")}
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext1 mt-3 text-xs">
|
||||||
|
$10 one-time · 3 devices · 14-day free trial
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Feature highlights ───────────────────────────────────────── */}
|
||||||
|
<section class="bg-base relative z-20 px-4 py-20 md:px-8">
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||||
|
Quiet power, right where it belongs
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-2">
|
||||||
|
{FEATURES.map((feature) => (
|
||||||
|
<div class="border-overlay0 bg-surface0 rounded-lg border p-6">
|
||||||
|
<h3 class="text-text mb-2 text-xl font-semibold">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── CTA ──────────────────────────────────────────────────────── */}
|
||||||
|
<section class="bg-surface0 relative z-20 px-4 py-20 md:px-8">
|
||||||
|
<div class="mx-auto max-w-4xl text-center">
|
||||||
|
<h2 class="text-text mb-4 text-3xl font-bold">
|
||||||
|
Try it free for 14 days
|
||||||
|
</h2>
|
||||||
|
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
|
||||||
|
Watch temperatures, take control of your fans, and keep background
|
||||||
|
services quiet. When the trial ends, unlock everything with a
|
||||||
|
single one-time payment — no subscription, ever.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="lg"
|
||||||
|
color={brandColor()}
|
||||||
|
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
|
||||||
|
>
|
||||||
|
Download trial
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
|
||||||
|
href="/checkout"
|
||||||
|
>
|
||||||
|
Buy a license → $10
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext1 mt-6 text-xs">
|
||||||
|
One license covers up to 3 of your own Macs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/routes/nook/privacy.tsx
Normal file
107
src/routes/nook/privacy.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* The Nook privacy policy — `nook.freno.me/privacy`.
|
||||||
|
*
|
||||||
|
* Reflects the actual data The Nook sends when licensing: a masked hardware
|
||||||
|
* UUID + device name for trial/activation limits, the email used for purchase,
|
||||||
|
* and Stripe payment processing.
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
|
export default function NookPrivacyPolicy() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title="Privacy Policy"
|
||||||
|
description="Privacy policy for The Nook, a native macOS hardware and system control app."
|
||||||
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
|
<div class="py-4 text-xl">The Nook's Privacy Policy</div>
|
||||||
|
<div class="py-2">Last Updated: August 26, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
Welcome to The Nook ('We', 'Us', 'Our').
|
||||||
|
Your privacy is important to us. This policy explains what we collect,
|
||||||
|
why, and how it is handled. The app itself runs locally on your device
|
||||||
|
and sends only the minimal data required for trial and licensing.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Device hardware UUID:</div> When you start
|
||||||
|
a trial or activate a license, The Nook sends a hardware UUID
|
||||||
|
from your Mac. It is used solely to enforce the 14-day trial and
|
||||||
|
the 3-device activation limit, and to prevent resetting the
|
||||||
|
trial by reinstalling the app.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Device name:</div> The Nook sends your
|
||||||
|
Mac's name so you can recognize the device in your license
|
||||||
|
activations. It is not used for any other purpose.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Email:</div> The email you provide at
|
||||||
|
checkout is used to deliver your license key and to look up your
|
||||||
|
order.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Payment:</div> Payments are processed by
|
||||||
|
Stripe. We never see or store your card details.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
Device identifiers, your email, and license records are used only
|
||||||
|
to operate the trial, deliver purchases, and enforce the 3-device
|
||||||
|
license. We do not use them for advertising or sell them to anyone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">3.</span> Data Security
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
License keys are signed and verified with Ed25519. The connection
|
||||||
|
to our servers uses TLS. Your license key is stored in the macOS
|
||||||
|
Keychain on your Mac.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">4.</span> Changes to the Privacy Policy
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
We may update this policy periodically. Any changes are posted on
|
||||||
|
this page, so please review it from time to time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">5.</span> Contact Us
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
If you have any questions about this policy, you can contact us{" "}
|
||||||
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
|
here
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/routes/nook/success.tsx
Normal file
112
src/routes/nook/success.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { createSignal, Show, onMount } from "solid-js";
|
||||||
|
import { useSearchParams } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import Button from "~/components/ui/Button";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 1500;
|
||||||
|
const MAX_POLLS = 8;
|
||||||
|
|
||||||
|
export default function NookSuccess() {
|
||||||
|
const site = useSite();
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const sessionId = () => (searchParams.session_id as string | undefined) ?? "";
|
||||||
|
|
||||||
|
const brandColor = () =>
|
||||||
|
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
|
||||||
|
|
||||||
|
const [key, setKey] = createSignal<string | null>(null);
|
||||||
|
const [failed, setFailed] = createSignal(false);
|
||||||
|
const [copied, setCopied] = createSignal(false);
|
||||||
|
|
||||||
|
const copyKey = async () => {
|
||||||
|
const value = key();
|
||||||
|
if (!value) return;
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!sessionId()) {
|
||||||
|
setFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < MAX_POLLS; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/the-nook/by-session?session_id=${encodeURIComponent(sessionId())}`
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as { key: string };
|
||||||
|
setKey(data.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// transient — keep polling
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title="Order complete"
|
||||||
|
description="Your The Nook license key."
|
||||||
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
|
|
||||||
|
<div class="bg-base mx-auto max-w-xl px-4 py-16">
|
||||||
|
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
|
||||||
|
<Show
|
||||||
|
when={key()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={failed()}
|
||||||
|
fallback={
|
||||||
|
<div>
|
||||||
|
<h1 class="text-text mb-2 text-2xl font-bold">
|
||||||
|
Confirming your order…
|
||||||
|
</h1>
|
||||||
|
<p class="text-subtext0 text-sm">
|
||||||
|
Checking payment and preparing your license key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-text mb-2 text-2xl font-bold">Thank you</h1>
|
||||||
|
<p class="text-subtext0 text-sm">
|
||||||
|
Check your email for your license key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 class="text-text mb-2 text-2xl font-bold">Thanks for buying The Nook</h1>
|
||||||
|
<p class="text-subtext0 mb-6 text-sm">
|
||||||
|
Here is your license key. Open The Nook, go to Settings, and enter it to activate.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="border-overlay0 bg-base mb-4 flex items-center justify-between gap-3 rounded-lg border p-4">
|
||||||
|
<code class="text-text break-all text-sm">{key()}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="download" size="md" color={brandColor()} onClick={copyKey}>
|
||||||
|
{copied() ? "Copied" : "Copy key"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p class="text-subtext1 mt-4 text-xs">
|
||||||
|
We also emailed this key to you. You can activate up to 3 devices.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
>;
|
|
||||||
@@ -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<
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = ?
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
ConnectionFactory,
|
ConnectionFactory,
|
||||||
LineageConnectionFactory,
|
LineageConnectionFactory,
|
||||||
NessaConnectionFactory
|
NessaConnectionFactory,
|
||||||
|
NookConnectionFactory
|
||||||
} from "~/server/db-connections";
|
} from "~/server/db-connections";
|
||||||
// Re-export connection factories to avoid circular import with auth.ts
|
// Re-export connection factories to avoid circular import with auth.ts
|
||||||
export { ConnectionFactory, LineageConnectionFactory, NessaConnectionFactory };
|
export {
|
||||||
|
ConnectionFactory,
|
||||||
|
LineageConnectionFactory,
|
||||||
|
NessaConnectionFactory,
|
||||||
|
NookConnectionFactory
|
||||||
|
};
|
||||||
|
|
||||||
export async function LineageDBInit() {
|
export async function LineageDBInit() {
|
||||||
const turso = createAPIClient({
|
const turso = createAPIClient({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createClient } from "@libsql/client/web";
|
import { createClient, type Client } from "@libsql/client/web";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
|
|
||||||
let mainDBConnection: ReturnType<typeof createClient> | null = null;
|
let mainDBConnection: Client | null = null;
|
||||||
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
|
let lineageDBConnection: Client | null = null;
|
||||||
let nessaDBConnection: ReturnType<typeof createClient> | null = null;
|
let nessaDBConnection: Client | null = null;
|
||||||
|
let nookDBConnection: Client | null = null;
|
||||||
|
|
||||||
export function ConnectionFactory() {
|
export function ConnectionFactory() {
|
||||||
if (!mainDBConnection) {
|
if (!mainDBConnection) {
|
||||||
@@ -37,3 +38,14 @@ export function NessaConnectionFactory() {
|
|||||||
}
|
}
|
||||||
return nessaDBConnection;
|
return nessaDBConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NookConnectionFactory() {
|
||||||
|
if (!nookDBConnection) {
|
||||||
|
const config = {
|
||||||
|
url: env.NOOK_DB_URL,
|
||||||
|
authToken: env.NOOK_DB_TOKEN
|
||||||
|
};
|
||||||
|
nookDBConnection = createClient(config);
|
||||||
|
}
|
||||||
|
return nookDBConnection;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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=()"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
114
src/server/nook.ts
Normal file
114
src/server/nook.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { env } from "~/env/server";
|
||||||
|
import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Nook license schema + issueLicense helper.
|
||||||
|
*
|
||||||
|
* Kept out of `database.ts` to avoid circular imports: this module owns the
|
||||||
|
* dedicated The Nook Turso DB and the Ed25519 license-key signing. The schema
|
||||||
|
* is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
|
||||||
|
* tool is needed.
|
||||||
|
*
|
||||||
|
* The license key is a compact printable string:
|
||||||
|
*
|
||||||
|
* key = payloadJson + "." + base64url(ed25519-signature(payloadJson))
|
||||||
|
*
|
||||||
|
* Canonicalization is load-bearing. The Swift client verifies the EXACT
|
||||||
|
* payload bytes (the substring before the last "."), never a re-serialization
|
||||||
|
* of the decoded JSON — key order must stay stable. Without a compact,
|
||||||
|
* deterministic payload this breaks, so `issueLicense` builds the payload as
|
||||||
|
* a hand-ordered literal and `JSON.stringify`s it in place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface IssueLicenseResult {
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYLOAD_VERSION = 1;
|
||||||
|
|
||||||
|
export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
await conn.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
stripe_session_id TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await conn.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS activations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
license_id TEXT NOT NULL REFERENCES licenses(id),
|
||||||
|
device_fingerprint TEXT NOT NULL,
|
||||||
|
device_name TEXT NOT NULL,
|
||||||
|
activated_at TEXT NOT NULL,
|
||||||
|
deactivated_at TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await conn.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS trials (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
started_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function privateKeyObject() {
|
||||||
|
return createPrivateKey({
|
||||||
|
key: Buffer.from(env.NOOK_LICENSE_PRIVATE_KEY, "base64"),
|
||||||
|
format: "der",
|
||||||
|
type: "pkcs8"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(payload: string): string {
|
||||||
|
const signature = sign(null, Buffer.from(payload, "utf8"), privateKeyObject());
|
||||||
|
return Buffer.from(signature).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-verifies a license key's Ed25519 signature server-side (defense in depth). */
|
||||||
|
export function verifyLicenseKey(key: string): boolean {
|
||||||
|
const token = key.match(/^([^]*?)\.([A-Za-z0-9_-]+)$/);
|
||||||
|
if (!token) return false;
|
||||||
|
const [, payload, sigB64] = token;
|
||||||
|
const publicKey = createPublicKey(privateKeyObject());
|
||||||
|
let signature: Buffer;
|
||||||
|
try {
|
||||||
|
signature = Buffer.from(sigB64!, "base64url");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return verify(null, Buffer.from(payload!, "utf8"), publicKey, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a license key for a completed Stripe checkout session.
|
||||||
|
*
|
||||||
|
* Caller is responsible for the uniqueness/idempotency of `stripeSessionId`
|
||||||
|
* (the `licenses.stripe_session_id` column is UNIQUE; the webhook catches the
|
||||||
|
* conflict and skips re-emailing).
|
||||||
|
*/
|
||||||
|
export async function issueLicense(
|
||||||
|
email: string,
|
||||||
|
stripeSessionId: string
|
||||||
|
): Promise<IssueLicenseResult> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
v: PAYLOAD_VERSION,
|
||||||
|
lid: id,
|
||||||
|
email: email,
|
||||||
|
iat: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
const key = `${payload}.${signPayload(payload)}`;
|
||||||
|
await NookConnectionFactory().execute({
|
||||||
|
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0)`,
|
||||||
|
args: [id, key, email, stripeSessionId, new Date().toISOString()]
|
||||||
|
});
|
||||||
|
return { key, id };
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/nessa/$1" },
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/nessa/$1" },
|
||||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/lineage/$1" },
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/lineage/$1" },
|
||||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" },
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" },
|
||||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" }
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "nook.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "nook.freno.me" }], "destination": "/nook/$1" }
|
||||||
],
|
],
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user