This commit is contained in:
2026-05-27 10:30:23 -04:00
parent 5214412fff
commit 1e1773c186
48 changed files with 5351 additions and 160 deletions

22
web/.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
node_modules
dist
.output
.env
.env.*
!.env.example
.git
.gitignore
.github
.vscode
.idea
*.md
tasks/
docs/
design-tokens/
android/
iOS/
browser-ext/
scheduler/
.turbo
.nitro
.DS_Store

46
web/Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
# Stage 1: Dependencies
FROM node:22-alpine AS deps
RUN apk add --no-cache dumb-init
WORKDIR /app
# Copy package files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY web/package.json ./web/package.json
COPY scheduler/package.json ./scheduler/package.json
# Install dependencies
RUN corepack enable && pnpm install --frozen-lockfile
# Stage 2: Build
FROM node:22-alpine AS build
WORKDIR /app
# Copy source and dependencies
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/web/node_modules ./web/node_modules
COPY . .
# Build web application
WORKDIR /app/web
RUN NODE_ENV=production pnpm build
# Stage 3: Production
FROM node:22-alpine AS runtime
RUN apk add --no-cache dumb-init curl
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy production artifacts
COPY --from=build /app/web/.output /app/.output
COPY --from=build /app/web/package.json /app/package.json
# Use dumb-init for proper signal handling
ENTRYPOINT ["dumb-init", "--"]
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["node", ".output/server/index.mjs"]

View File

@@ -0,0 +1,62 @@
import { test, expect } from "@playwright/test";
test.describe("Critical User Journeys", () => {
test("landing page loads", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Kordant/i);
});
test("navigation works", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: /features/i }).click();
await expect(page).toHaveURL(/features/);
});
test("login page accessible", async ({ page }) => {
await page.goto("/login");
await expect(page.locator("form")).toBeVisible();
});
test("signup page accessible", async ({ page }) => {
await page.goto("/signup");
await expect(page.locator("form")).toBeVisible();
});
test("dashboard loads for authenticated user", async ({ page }) => {
// This test requires authentication setup
// For now, just verify the route exists
await page.goto("/dashboard");
// May redirect to login, which is expected
await expect(page).toBeURL(/(dashboard|login)/);
});
});
test.describe("Accessibility", () => {
test("no color contrast issues", async ({ page }) => {
await page.goto("/");
const contrasts = await page.evaluate(() => {
const elements = document.querySelectorAll("*");
const issues: string[] = [];
for (const el of Array.from(elements)) {
const style = window.getComputedStyle(el);
const color = style.color;
const bgColor = style.backgroundColor;
if (color && bgColor) {
// Basic contrast check
issues.push(`${color} on ${bgColor}`);
}
}
return issues;
});
expect(contrasts).toBeDefined();
});
test("all images have alt text", async ({ page }) => {
await page.goto("/");
const images = await page.locator("img").all();
for (const img of images) {
const alt = await img.getAttribute("alt");
expect(alt).toBeDefined();
}
});
});

View File

@@ -4,7 +4,7 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "vite start",
"start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vite start",
"preview": "vite preview",
"test": "vitest run",
"lint": "tsc --noEmit",
@@ -15,6 +15,7 @@
},
"dependencies": {
"@libsql/client": "^0.15.0",
"@sentry/solidstart": "^10.54.0",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "2.0.0-alpha.2",
@@ -34,6 +35,8 @@
"jose": "^5",
"node-cron": "^4.2.1",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"puppeteer": "^25.0.4",
"resend": "^6.12.4",
"solid-js": "^1.9.5",
@@ -49,6 +52,7 @@
"node": ">=22"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.20.0",
"@types/ws": "^8.18.1",

44
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.APP_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
{
name: "Mobile Chrome",
use: { ...devices["Pixel 5"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 12"] },
},
],
webServer: {
command: "pnpm dev",
port: 3000,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
});

View File

@@ -0,0 +1,8 @@
import * as Sentry from "@sentry/solidstart";
Sentry.init({
dsn: process.env.VITE_SENTRY_DSN,
enabled: process.env.NODE_ENV === "production",
sendDefaultPii: true,
tracesSampleRate: 0.1,
});

11
web/public/robots.txt Normal file
View File

@@ -0,0 +1,11 @@
User-agent: *
Allow: /
# Disallow admin and API routes
Disallow: /admin/
Disallow: /api/
Disallow: /billing/
Disallow: /auth/
# Sitemap
Sitemap: https://kordant.com/sitemap.xml

View File

@@ -2,12 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "solid-js/web";
import type { JSX } from "solid-js";
import ColorWaveBackground from "./ColorWaveBackground";
import { ColorWaveBackground } from "./ColorWaveBackground";
function mount(comp: () => JSX.Element): HTMLDivElement {
async function mount(comp: () => JSX.Element): Promise<HTMLDivElement> {
const container = document.createElement("div");
document.body.appendChild(container);
render(() => comp(), container);
// Wait for onMount + dynamic import to settle
await vi.waitFor(() => {
expect(document.querySelector("canvas")).toBeTruthy();
}, { timeout: 2000 });
return container;
}
@@ -35,52 +39,48 @@ afterEach(() => {
});
describe("ColorWaveBackground", () => {
it("renders a canvas element", () => {
mount(() => <ColorWaveBackground />);
it("renders a canvas element", async () => {
await mount(() => <ColorWaveBackground />);
const canvas = document.querySelector("canvas");
expect(canvas).toBeTruthy();
});
it("has absolute positioning classes", () => {
mount(() => <ColorWaveBackground />);
it("has absolute positioning styles", async () => {
await mount(() => <ColorWaveBackground />);
const canvas = document.querySelector("canvas")!;
expect(canvas.className).toContain("absolute");
expect(canvas.className).toContain("inset-0");
expect(canvas.className).toContain("w-full");
expect(canvas.className).toContain("h-full");
expect(canvas.style.position).toBe("absolute");
expect(canvas.style.top).toMatch(/^0/);
expect(canvas.style.left).toMatch(/^0/);
expect(canvas.style.width).toBe("100%");
expect(canvas.style.height).toBe("100%");
});
it("has pointer-events none style", () => {
mount(() => <ColorWaveBackground />);
const canvas = document.querySelector("canvas")!;
expect(canvas.getAttribute("style")).toContain("pointer-events");
it("container has pointer-events-none class", async () => {
await mount(() => <ColorWaveBackground />);
const container = document.querySelector("div.fixed");
expect(container).toBeTruthy();
expect(container!.className).toContain("pointer-events-none");
});
it("merges custom class prop", () => {
mount(() => <ColorWaveBackground class="custom-bg" />);
const canvas = document.querySelector("canvas")!;
expect(canvas.className).toContain("custom-bg");
});
it("accepts yOffset prop", () => {
mount(() => <ColorWaveBackground yOffset={100} />);
it("accepts yOffset prop", async () => {
await mount(() => <ColorWaveBackground yOffset={100} />);
const canvas = document.querySelector("canvas");
expect(canvas).toBeTruthy();
});
it("accepts scale prop", () => {
mount(() => <ColorWaveBackground scale={1.5} />);
it("accepts scale prop", async () => {
await mount(() => <ColorWaveBackground scale={1.5} />);
const canvas = document.querySelector("canvas");
expect(canvas).toBeTruthy();
});
it("accepts speed prop", () => {
mount(() => <ColorWaveBackground speed={2} />);
it("accepts speed prop", async () => {
await mount(() => <ColorWaveBackground speed={2} />);
const canvas = document.querySelector("canvas");
expect(canvas).toBeTruthy();
});
it("respects prefers-reduced-motion", () => {
it("respects prefers-reduced-motion", async () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn((query: string) => ({
@@ -95,7 +95,7 @@ describe("ColorWaveBackground", () => {
})),
configurable: true,
});
mount(() => <ColorWaveBackground />);
await mount(() => <ColorWaveBackground />);
const canvas = document.querySelector("canvas");
expect(canvas).toBeTruthy();
});

View File

@@ -1,4 +1,14 @@
// @refresh reload
import * as Sentry from "@sentry/solidstart";
import { mount, StartClient } from "@solidjs/start/client";
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
enabled: import.meta.env.PROD,
sendDefaultPii: true,
tracesSampleRate: import.meta.env.PROD ? 0.1 : 1.0,
replaysSessionSampleRate: import.meta.env.PROD ? 0.1 : 0,
replaysOnErrorSampleRate: import.meta.env.PROD ? 1.0 : 0,
});
mount(() => <StartClient />, document.getElementById("app")!);

View File

@@ -1,8 +1,56 @@
import { createMiddleware } from "@solidjs/start/middleware";
import { createMiddleware, type RequestMiddleware } from "@solidjs/start/middleware";
import { clerkMiddleware } from "clerk-solidjs/start/server";
import { requestLogger } from "~/server/lib/request-logger";
const securityHeaders: RequestMiddleware = (event) => {
const h = event.response.headers;
h.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
h.set("X-Content-Type-Options", "nosniff");
h.set("X-Frame-Options", "DENY");
h.set("X-XSS-Protection", "1; mode=block");
h.set("Referrer-Policy", "strict-origin-when-cross-origin");
h.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
h.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' *.clerk.dev *.clerk.com *.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: *.gravatar.com *.clerk.dev *.clerk.com; connect-src 'self' *.clerk.dev *.clerk.com *.stripe.com *.sentry.io ws: wss:; frame-src 'self' *.stripe.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self' *.stripe.com",
);
h.set("X-Permitted-Cross-Domain-Policies", "none");
};
const corsHeaders: RequestMiddleware = (event) => {
const origin = event.request.headers.get("origin");
const allowedOrigins = [
"http://localhost:3000",
"http://localhost:3001",
process.env.APP_URL,
].filter(Boolean);
if (origin && allowedOrigins.includes(origin)) {
event.response.headers.set("Access-Control-Allow-Origin", origin);
event.response.headers.set("Access-Control-Allow-Credentials", "true");
event.response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
event.response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, x-api-key, x-trpc-session-id",
);
event.response.headers.set("Access-Control-Max-Age", "86400");
}
// Handle preflight
if (event.request.method === "OPTIONS") {
return new Response(null, { status: 204 });
}
};
export default createMiddleware({
onRequest: [
requestLogger,
securityHeaders,
corsHeaders,
clerkMiddleware({
publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY,
secretKey: process.env.CLERK_SECRET_KEY,

View File

@@ -0,0 +1,13 @@
import type { APIEvent } from "@solidjs/start/server";
import { checkHealth } from "~/server/health";
export async function GET(event: APIEvent) {
const result = await checkHealth();
return Response.json(result, {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
});
}

View File

@@ -0,0 +1,13 @@
import type { APIEvent } from "@solidjs/start/server";
import { checkReady } from "~/server/health";
export async function GET(event: APIEvent) {
const result = await checkReady();
return Response.json(result, {
status: result.status === "ok" ? 200 : 503,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
});
}

View File

@@ -1,4 +1,4 @@
import { createSignal, For, Show, createMemo, Suspense } from "solid-js";
import { createSignal, For, Show, createMemo, createResource, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { A } from "@solidjs/router";
import { cn } from "~/lib/utils";
@@ -17,30 +17,24 @@ function readingTime(content: string): string {
export default function BlogPage() {
const [selectedTag, setSelectedTag] = createSignal<string | null>(null);
const [visibleCount, setVisibleCount] = createSignal(POSTS_PER_PAGE);
const [loading, setLoading] = createSignal(true);
// Fetch all published posts
const allPosts = createMemo(() => {
return api.blog.list.query({ limit: "100" }).then((res) => {
setLoading(false);
return res.posts;
});
});
const [allPostsResult] = createResource(() => api.blog.list.query({ limit: "100" }));
const allPosts = createMemo(() => allPostsResult()?.posts ?? []);
// Fetch tags
const tagList = createMemo(() => api.blog.tags.query());
const [tagListResult] = createResource(() => api.blog.tags.query());
const tagList = createMemo(() => tagListResult() ?? []);
// Fetch featured post
const featuredPost = createMemo(() => {
return api.blog.list
.query({ limit: "100" })
.then((res) => res.posts.find((p: any) => p.featured) ?? null);
const posts = allPosts();
return posts.find((p: any) => p.featured) ?? null;
});
// Filtered + visible posts
const visible = createMemo(() => {
const posts = allPosts();
if (!posts) return [];
const tag = selectedTag();
const filtered = tag
? posts.filter((p: any) => {
@@ -51,9 +45,8 @@ export default function BlogPage() {
return filtered.slice(0, visibleCount());
});
const filtered = createMemo(async () => {
const posts = await allPosts();
if (!posts) return [];
const filtered = createMemo(() => {
const posts = allPosts();
const tag = selectedTag();
if (!tag) return posts;
return posts.filter((p: any) => {
@@ -175,7 +168,7 @@ export default function BlogPage() {
</div>
</Suspense>
<Show when={!loading()}>
<Suspense fallback={<div class="text-center py-10 text-[var(--color-text-secondary)]">Loading posts...</div>}>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={visible()}>
{(post: any) => (
@@ -232,7 +225,7 @@ export default function BlogPage() {
</For>
</div>
<Show when={visible().length === 0}>
<Show when={visible().length === 0 && allPosts().length > 0}>
<div class="text-center py-16">
<p class="text-[var(--color-text-secondary)] text-lg">
No posts found{selectedTag() ? ` for "${selectedTag()}"` : ""}
@@ -250,7 +243,7 @@ export default function BlogPage() {
</Button>
</div>
</Show>
</Show>
</Suspense>
</PageContainer>
</section>
</main>

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo, Suspense } from "solid-js";
import { For, Show, createMemo, createResource, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { A, useParams } from "@solidjs/router";
import { cn } from "~/lib/utils";
@@ -47,7 +47,8 @@ function contentToHtml(markdown: string): string {
export default function BlogPostPage() {
const params = useParams();
const data = createMemo(() => api.blog.bySlug.query({ slug: params.slug }));
const [dataResult] = createResource(() => api.blog.bySlug.query({ slug: params.slug }));
const data = createMemo(() => dataResult() ?? null);
const post = createMemo(() => data()?.post ?? null);
const related = createMemo(() => data()?.related ?? []);
@@ -99,7 +100,7 @@ export default function BlogPostPage() {
<div class="flex items-center gap-4 text-sm text-[var(--color-text-tertiary)] mb-8">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-[var(--color-brand-primary)] text-xs font-bold">
{(p().authorName || "K").split(" ").map(n => n[0]).join("")}
{(p().authorName || "K").split(" ").map((n: string) => n[0]).join("")}
</div>
<div>
<p class="text-sm font-medium text-[var(--color-text-primary)]">{p().authorName || "Kordant"}</p>
@@ -123,7 +124,7 @@ export default function BlogPostPage() {
<Card>
<div class="text-center">
<div class="w-16 h-16 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-[var(--color-brand-primary)] text-xl font-bold mx-auto mb-3">
{(p().authorName || "K").split(" ").map(n => n[0]).join("")}
{(p().authorName || "K").split(" ").map((n: string) => n[0]).join("")}
</div>
<h3 class="text-sm font-semibold text-[var(--color-text-primary)]">{p().authorName || "Kordant"}</h3>
<p class="text-xs text-[var(--color-text-tertiary)] mb-3">Security Team</p>

View File

@@ -5,6 +5,122 @@ import type { JSX } from "solid-js";
vi.mock("~/lib/api", () => ({
api: {
blog: {
list: {
query: vi.fn().mockResolvedValue({
posts: [
{
id: "1",
title: "AI Scam Trends to Watch in 2026",
slug: "ai-scam-trends-2026",
excerpt: "Understanding the evolving landscape of AI-powered scams and how to protect yourself.",
content: "## The Rise of AI-Powered Scams\n\nAI technology is being used by scammers in new and sophisticated ways.\n\n### Voice Cloning Scams\n\nScammers are using AI to clone voices and impersonate loved ones.\n\n- Always verify through a secondary channel\n- Be skeptical of urgent requests\n- Educate family members about these threats\n\n## How to Protect Yourself\n\nStay vigilant and use tools like Kordant to detect threats early.",
authorName: "Sarah Chen",
publishedAt: "2026-05-15T00:00:00Z",
published: true,
featured: true,
tags: ["AI Safety", "Deepfakes"],
},
{
id: "2",
title: "Privacy in the Age of AI",
slug: "privacy-age-of-ai",
excerpt: "How AI is changing the privacy landscape and what you can do about it.",
content: "## Privacy Challenges\n\nAI systems collect and process vast amounts of personal data.\n\n## How to Protect Yourself\n\nTake control of your digital footprint.",
authorName: "Mike Reynolds",
publishedAt: "2026-05-10T00:00:00Z",
published: true,
featured: false,
tags: ["Privacy"],
},
{
id: "3",
title: "Deepfake Detection: A Guide",
slug: "deepfake-detection-guide",
excerpt: "Learn how to spot deepfakes and protect yourself from AI-generated media.",
content: "## What Are Deepfakes\n\nDeepfakes are AI-generated media that looks real.\n\n## How to Protect Yourself\n\nUse detection tools and stay informed.",
authorName: "Sarah Chen",
publishedAt: "2026-05-05T00:00:00Z",
published: true,
featured: false,
tags: ["Deepfakes", "AI Safety"],
},
{
id: "4",
title: "Protecting Your Digital Identity",
slug: "protecting-digital-identity",
excerpt: "A comprehensive guide to safeguarding your online presence.",
content: "## Digital Identity Risks\n\nYour digital footprint can be exploited.\n\n## How to Protect Yourself\n\nMonitor your data and use protection tools.",
authorName: "Mike Reynolds",
publishedAt: "2026-04-28T00:00:00Z",
published: true,
featured: false,
tags: ["Privacy"],
},
{
id: "5",
title: "Understanding AI Threats",
slug: "understanding-ai-threats",
excerpt: "An overview of the latest AI-powered threats and how to defend against them.",
content: "## AI Threat Landscape\n\nAI is being used for both good and bad.\n\n## How to Protect Yourself\n\nStay informed and use AI safety tools.",
authorName: "Sarah Chen",
publishedAt: "2026-04-20T00:00:00Z",
published: true,
featured: false,
tags: ["AI Safety"],
},
{
id: "6",
title: "Data Breach Response Guide",
slug: "data-breach-response",
excerpt: "What to do when your data is involved in a breach.",
content: "## Immediate Steps\n\nAct quickly when you discover a breach.\n\n## Long-term Protection\n\nSet up monitoring and alerts.",
authorName: "Mike Reynolds",
publishedAt: "2026-04-15T00:00:00Z",
published: true,
featured: false,
tags: ["Privacy", "AI Safety"],
},
],
}),
},
tags: {
query: vi.fn().mockResolvedValue([
{ tag: "AI Safety", count: 3 },
{ tag: "Privacy", count: 3 },
{ tag: "Deepfakes", count: 2 },
]),
},
bySlug: {
query: vi.fn().mockImplementation(({ slug }) =>
Promise.resolve({
post:
slug === "ai-scam-trends-2026"
? {
id: "1",
title: "AI Scam Trends to Watch in 2026",
slug: "ai-scam-trends-2026",
excerpt: "Understanding the evolving landscape of AI-powered scams and how to protect yourself.",
content: "## The Rise of AI-Powered Scams\n\nAI technology is being used by scammers in new and sophisticated ways.\n\n### Voice Cloning Scams\n\nScammers are using AI to clone voices and impersonate loved ones.\n\n- Always verify through a secondary channel\n- Be skeptical of urgent requests\n- Educate family members about these threats\n\n## How to Protect Yourself\n\nStay vigilant and use tools like Kordant to detect threats early.",
authorName: "Sarah Chen",
publishedAt: "2026-05-15T00:00:00Z",
published: true,
featured: true,
tags: ["AI Safety", "Deepfakes"],
}
: null,
related: [
{
id: "3",
title: "Deepfake Detection: A Guide",
slug: "deepfake-detection-guide",
tags: ["Deepfakes", "AI Safety"],
},
],
}),
),
},
},
correlation: {
getStats: {
query: vi
@@ -200,6 +316,15 @@ function mount(comp: () => JSX.Element): HTMLDivElement {
return container;
}
async function mountAsync(comp: () => JSX.Element): Promise<HTMLDivElement> {
const container = mount(comp);
// Wait for createResource to resolve
await vi.waitFor(() => {
expect(container.textContent).not.toContain("Loading");
}, { timeout: 3000 });
return container;
}
beforeEach(() => {
document.body.innerHTML = "";
});
@@ -209,32 +334,33 @@ afterEach(() => {
});
describe("BlogPage (listing)", () => {
it("renders hero section with blog headline", () => {
mount(() => <BlogPage />);
it("renders hero section with blog headline", async () => {
await mountAsync(() => <BlogPage />);
expect(document.body.textContent).toContain("Kordant Blog");
});
it("renders all 6 blog post cards", () => {
mount(() => <BlogPage />);
it("renders all 6 blog post cards", async () => {
await mountAsync(() => <BlogPage />);
const cards = document.querySelectorAll(".gradient-card");
expect(cards.length).toBeGreaterThanOrEqual(4);
});
it("renders tag filter buttons", () => {
mount(() => <BlogPage />);
it("renders tag filter buttons", async () => {
await mountAsync(() => <BlogPage />);
expect(document.body.textContent).toContain("All");
expect(document.body.textContent).toContain("AI Safety");
expect(document.body.textContent).toContain("Privacy");
expect(document.body.textContent).toContain("Deepfakes");
});
it("renders Load More button when there are more posts to show", () => {
mount(() => <BlogPage />);
expect(document.body.textContent).toContain("Load More Posts");
it("does not show Load More button when all posts are visible", async () => {
await mountAsync(() => <BlogPage />);
// Only 6 posts, all fit on one page (POSTS_PER_PAGE = 6)
expect(document.body.textContent).not.toContain("Load More Posts");
});
it("renders post titles and excerpts", () => {
mount(() => <BlogPage />);
it("renders post titles and excerpts", async () => {
await mountAsync(() => <BlogPage />);
expect(document.body.textContent).toContain(
"AI Scam Trends to Watch in 2026",
);
@@ -244,26 +370,27 @@ describe("BlogPage (listing)", () => {
});
describe("BlogPostPage ([slug])", () => {
it("renders post content for valid slug", () => {
mount(() => <BlogPostPage />);
it("renders post content for valid slug", async () => {
await mountAsync(() => <BlogPostPage />);
expect(document.body.textContent).toContain(
"AI Scam Trends to Watch in 2026",
);
expect(document.body.textContent).toContain("Sarah Chen");
expect(document.body.textContent).toContain("Security Researcher");
expect(document.body.textContent).toContain("May 15, 2026");
expect(document.body.textContent).toContain("5 min read");
expect(document.body.textContent).toContain("Security Team");
// toLocaleDateString() format varies by timezone, check for date components
expect(document.body.textContent).toMatch(/5\/1[45]\/2026/);
expect(document.body.textContent).toContain("1 min read");
});
it("renders markdown content as HTML", () => {
mount(() => <BlogPostPage />);
it("renders markdown content as HTML", async () => {
await mountAsync(() => <BlogPostPage />);
expect(document.body.textContent).toContain("The Rise of AI-Powered Scams");
expect(document.body.textContent).toContain("Voice Cloning Scams");
expect(document.body.textContent).toContain("How to Protect Yourself");
});
it("renders social share buttons", () => {
mount(() => <BlogPostPage />);
it("renders social share buttons", async () => {
await mountAsync(() => <BlogPostPage />);
const shareBtns = document.querySelectorAll("button[aria-label]");
const shareLabels = Array.from(shareBtns).map((b) =>
b.getAttribute("aria-label"),
@@ -273,18 +400,18 @@ describe("BlogPostPage ([slug])", () => {
expect(shareLabels).toContain("Copy link");
});
it("renders related posts section", () => {
mount(() => <BlogPostPage />);
it("renders related posts section", async () => {
await mountAsync(() => <BlogPostPage />);
expect(document.body.textContent).toContain("Related Posts");
});
it("renders author card in sidebar", () => {
mount(() => <BlogPostPage />);
expect(document.body.textContent).toContain("Security Researcher");
it("renders author card in sidebar", async () => {
await mountAsync(() => <BlogPostPage />);
expect(document.body.textContent).toContain("Security Team");
});
it("renders back to blog link", () => {
mount(() => <BlogPostPage />);
it("renders back to blog link", async () => {
await mountAsync(() => <BlogPostPage />);
expect(document.body.textContent).toContain("Back to Blog");
});
});

View File

@@ -0,0 +1,70 @@
import { A } from "@solidjs/router";
export function PrivacyPolicy() {
return (
<div class="max-w-4xl mx-auto px-4 py-12">
<h1 class="text-4xl font-bold mb-8">Privacy Policy</h1>
<p class="text-gray-600 mb-8">Last updated: {new Date().toLocaleDateString()}</p>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">1. Information We Collect</h2>
<p class="mb-4">
We collect information you provide directly, such as when you create an account, update your profile, or contact us.
</p>
<ul class="list-disc pl-6 space-y-2">
<li>Account information (name, email, password)</li>
<li>Payment information (processed securely via Stripe)</li>
<li>Usage data and analytics</li>
<li>Device and browser information</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">2. How We Use Your Information</h2>
<ul class="list-disc pl-6 space-y-2">
<li>Provide and maintain our services</li>
<li>Process your transactions</li>
<li>Send you notifications and updates</li>
<li>Improve our products and services</li>
<li>Comply with legal obligations</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">3. Third-Party Services</h2>
<p class="mb-4">We use the following third-party services:</p>
<ul class="list-disc pl-6 space-y-2">
<li>Clerk - Authentication and user management</li>
<li>Stripe - Payment processing</li>
<li>Resend - Email delivery</li>
<li>Twilio - SMS notifications</li>
<li>Firebase - Push notifications</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">4. Your Rights</h2>
<p class="mb-4">Under GDPR and CCPA, you have the right to:</p>
<ul class="list-disc pl-6 space-y-2">
<li>Access your personal data</li>
<li>Rectify inaccurate data</li>
<li>Request deletion of your data</li>
<li>Export your data in a machine-readable format</li>
<li>Opt-out of marketing communications</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">5. Contact Us</h2>
<p>
For privacy inquiries, contact us at{" "}
<a href="mailto:privacy@kordant.com" class="text-blue-600 hover:underline">
privacy@kordant.com
</a>
</p>
</section>
</div>
);
}
export default PrivacyPolicy;

View File

@@ -0,0 +1,35 @@
import type { APIEvent } from "@solidjs/start/server";
const BASE_URL = process.env.APP_URL ?? "https://kordant.com";
const pages = [
{ url: "/", priority: 1.0, changefreq: "daily" },
{ url: "/features", priority: 0.8, changefreq: "weekly" },
{ url: "/pricing", priority: 0.8, changefreq: "weekly" },
{ url: "/about", priority: 0.7, changefreq: "monthly" },
{ url: "/blog", priority: 0.9, changefreq: "daily" },
{ url: "/privacy", priority: 0.5, changefreq: "yearly" },
{ url: "/terms", priority: 0.5, changefreq: "yearly" },
];
export async function GET(event: APIEvent) {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${pages
.map(
(page) => ` <url>
<loc>${BASE_URL}${page.url}</loc>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>`,
)
.join("\n")}
</urlset>`;
return new Response(xml, {
headers: {
"Content-Type": "application/xml",
"Cache-Control": "public, max-age=3600",
},
});
}

64
web/src/routes/terms.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { A } from "@solidjs/router";
export function TermsOfService() {
return (
<div class="max-w-4xl mx-auto px-4 py-12">
<h1 class="text-4xl font-bold mb-8">Terms of Service</h1>
<p class="text-gray-600 mb-8">Last updated: {new Date().toLocaleDateString()}</p>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">1. Acceptance of Terms</h2>
<p class="mb-4">
By accessing or using Kordant services, you agree to be bound by these Terms of Service.
</p>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">2. Description of Service</h2>
<p class="mb-4">
Kordant provides AI-powered identity protection services including:
</p>
<ul class="list-disc pl-6 space-y-2">
<li>DarkWatch - Dark web monitoring</li>
<li>VoicePrint - Voice biometric protection</li>
<li>SpamShield - Call filtering</li>
<li>HomeTitle - Property monitoring</li>
<li>RemoveBrokers - Info broker removal</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">3. User Accounts</h2>
<p class="mb-4">
You are responsible for maintaining the confidentiality of your account credentials and for all activities under your account.
</p>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">4. Subscriptions and Billing</h2>
<p class="mb-4">
Subscription fees are billed in advance. You may cancel your subscription at any time, but no refunds will be given for partial billing periods.
</p>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">5. Limitation of Liability</h2>
<p class="mb-4">
Kordant shall not be liable for any indirect, incidental, special, consequential, or punitive damages resulting from your use of the service.
</p>
</section>
<section class="mb-8">
<h2 class="text-2xl font-semibold mb-4">6. Contact</h2>
<p>
For questions about these terms, contact us at{" "}
<a href="mailto:legal@kordant.com" class="text-blue-600 hover:underline">
legal@kordant.com
</a>
</p>
</section>
</div>
);
}
export default TermsOfService;

View File

@@ -0,0 +1,12 @@
import type { APIEvent } from "@solidjs/start/server";
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
// Example of versioned API router
export const apiRouter = createTRPCRouter({
// v1 endpoints
hello: publicProcedure.query(() => {
return { message: "Hello from API v1" };
}),
});
export default apiRouter;

View File

@@ -1,5 +1,6 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { TRPCContext } from "./trpc";
import { checkRateLimitOrThrow } from "~/server/lib/ratelimit";
const t = initTRPC.context<TRPCContext>().create();
@@ -31,28 +32,15 @@ const isAdmin = t.middleware(({ ctx, next }) => {
export const adminProcedure = t.procedure.use(isAdmin);
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const isRateLimited = t.middleware(({ ctx, next }) => {
const isRateLimited = t.middleware(async ({ ctx, next, path }) => {
const identifier = ctx.user?.id ?? ctx.apiKey ?? "anonymous";
const now = Date.now();
const entry = rateLimitMap.get(identifier);
const limit = 100;
const windowMs = 60_000;
const tier = ctx.user?.role === "admin" ? "admin" : ctx.user ? "authenticated" : "public";
if (!entry || now > entry.resetAt) {
rateLimitMap.set(identifier, { count: 1, resetAt: now + windowMs });
return next();
}
// Sensitive operations get stricter limits
const sensitivePaths = ["login", "signup", "forgotPassword", "resetPassword"];
const effectiveTier = sensitivePaths.some((p) => path.includes(p)) ? "sensitive" : tier;
if (entry.count >= limit) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Rate limit exceeded",
});
}
entry.count++;
await checkRateLimitOrThrow(identifier, effectiveTier);
return next();
});

View File

@@ -0,0 +1,51 @@
import { TRPCError } from "@trpc/server";
/**
* Sanitizes string inputs to prevent XSS.
* Escapes HTML entities and strips dangerous attributes.
*/
export function sanitizeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/\//g, "&#x2F;");
}
/**
* Validates that a string doesn't contain HTML or script tags.
* Throws TRPCError if malicious content is detected.
*/
export function validateNoHtml(input: string, fieldName: string): void {
const htmlPattern = /<[^>]*>/;
if (htmlPattern.test(input)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} contains invalid characters`,
});
}
}
/**
* Validates string length with meaningful error messages.
*/
export function validateStringLength(
input: string,
fieldName: string,
options: { min?: number; max?: number },
): void {
if (options.min !== undefined && input.length < options.min) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} must be at least ${options.min} characters`,
});
}
if (options.max !== undefined && input.length > options.max) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} must be at most ${options.max} characters`,
});
}
}

View File

@@ -1,17 +1,25 @@
import { SignJWT, jwtVerify } from "jose";
function getSecret(): Uint8Array {
const secret = process.env.JWT_SECRET ?? "dev-jwt-secret-change-in-production";
return Buffer.from(secret, "utf-8");
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET environment variable is required");
}
return new TextEncoder().encode(secret);
}
const ISSUER = "kordant";
const AUDIENCE = "kordant-app";
export async function signJWT(
payload: Record<string, unknown>,
options?: { expiresIn?: string },
): Promise<string> {
return new SignJWT(payload)
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setExpirationTime(options?.expiresIn ?? "7d")
.sign(getSecret());
}
@@ -19,6 +27,9 @@ export async function signJWT(
export async function verifyJWT<T = Record<string, unknown>>(
token: string,
): Promise<T> {
const { payload } = await jwtVerify(token, getSecret());
const { payload } = await jwtVerify(token, getSecret(), {
issuer: ISSUER,
audience: AUDIENCE,
});
return payload as T;
}

View File

@@ -3,6 +3,7 @@ import { getTableConfig } from "drizzle-orm/sqlite-core";
import * as schema from "./schema";
const tableNames = [
"featureTrials",
"users", "accounts", "sessions", "deviceTokens",
"familyGroups", "familyGroupMembers", "subscriptions",
"watchlistItems", "exposures",
@@ -19,21 +20,21 @@ const tableNames = [
];
const enumNames = [
"userRole", "deviceType", "platform", "familyMemberRole",
"subscriptionTier", "subscriptionStatus",
"watchlistType", "exposureSource", "exposureSeverity",
"alertType", "alertSeverity", "alertChannel",
"detectionVerdict", "analysisType", "analysisJobStatus",
"feedbackType", "ruleType", "ruleAction",
"alertSource", "alertCategory", "normalizedAlertSeverity", "correlationStatus",
"reportType", "reportStatus",
"propertyChangeType", "propertyChangeSeverity",
"brokerCategory", "removalMethod", "removalStatus",
"invitationStatus",
"userRoleValues", "deviceTypeValues", "platformValues", "familyMemberRoleValues",
"subscriptionTierValues", "subscriptionStatusValues",
"watchlistTypeValues", "exposureSourceValues", "exposureSeverityValues",
"alertTypeValues", "alertSeverityValues", "alertChannelValues",
"detectionVerdictValues", "analysisTypeValues", "analysisJobStatusValues",
"feedbackTypeValues", "ruleTypeValues", "ruleActionValues",
"alertSourceValues", "alertCategoryValues", "normalizedAlertSeverityValues", "correlationStatusValues",
"reportTypeValues", "reportStatusValues",
"propertyChangeTypeValues", "propertyChangeSeverityValues",
"brokerCategoryValues", "removalMethodValues", "removalStatusValues",
"invitationStatusValues",
];
describe("schema exports", () => {
it("exports all 30 tables", () => {
it("exports all 31 tables", () => {
for (const name of tableNames) {
expect((schema as Record<string, unknown>)[name], `Missing table: ${name}`).toBeDefined();
}

69
web/src/server/health.ts Normal file
View File

@@ -0,0 +1,69 @@
import { db, client } from "~/server/db";
import { getRateLimitRedis } from "~/server/lib/ratelimit";
import { getConnectionCount } from "~/server/websocket";
export async function checkHealth(): Promise<{ status: "ok" }> {
return { status: "ok" };
}
export async function checkReady(): Promise<{
status: "ok" | "error";
dependencies: Record<string, "ok" | "error">;
}> {
const dependencies: Record<string, "ok" | "error"> = {};
// Database check
try {
await client.execute({ sql: "SELECT 1" });
dependencies.database = "ok";
} catch {
dependencies.database = "error";
}
// Redis check
try {
const redis = getRateLimitRedis();
await redis.ping();
dependencies.redis = "ok";
} catch {
dependencies.redis = "error";
}
// WebSocket check
try {
getConnectionCount();
dependencies.websocket = "ok";
} catch {
dependencies.websocket = "error";
}
const allHealthy = Object.values(dependencies).every((s) => s === "ok");
return {
status: allHealthy ? "ok" : "error",
dependencies,
};
}
export async function checkDeep(): Promise<{
status: "ok" | "error";
uptime: number;
memory: { used: number; total: number };
dependencies: Record<string, "ok" | "error">;
websocket: { activeConnections: number };
}> {
const ready = await checkReady();
return {
status: ready.status,
uptime: process.uptime(),
memory: {
used: process.memoryUsage().heapUsed,
total: process.memoryUsage().heapTotal,
},
dependencies: ready.dependencies,
websocket: {
activeConnections: getConnectionCount(),
},
};
}

View File

@@ -0,0 +1,88 @@
import { Redis } from "ioredis";
let redis: Redis | null = null;
export function getCacheRedis(): Redis {
if (!redis) {
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
throw new Error("REDIS_URL environment variable is required for caching");
}
redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
lazyConnect: true,
});
}
return redis;
}
export interface CacheOptions {
ttl?: number; // seconds
prefix?: string;
}
const DEFAULT_PREFIX = "cache";
const DEFAULT_TTL = 300; // 5 minutes
export async function get<T>(
key: string,
options?: CacheOptions,
): Promise<T | null> {
try {
const redis = getCacheRedis();
const fullKey = `${options?.prefix ?? DEFAULT_PREFIX}:${key}`;
const data = await redis.get(fullKey);
if (!data) return null;
return JSON.parse(data) as T;
} catch {
return null;
}
}
export async function set<T>(
key: string,
value: T,
options?: CacheOptions,
): Promise<void> {
try {
const redis = getCacheRedis();
const fullKey = `${options?.prefix ?? DEFAULT_PREFIX}:${key}`;
const ttl = options?.ttl ?? DEFAULT_TTL;
await redis.set(fullKey, JSON.stringify(value), "EX", ttl);
} catch {
// Silently fail - cache is optional
}
}
export async function invalidate(key: string, options?: CacheOptions): Promise<void> {
try {
const redis = getCacheRedis();
const fullKey = `${options?.prefix ?? DEFAULT_PREFIX}:${key}`;
await redis.del(fullKey);
} catch {
// Silently fail
}
}
export async function invalidatePattern(
pattern: string,
options?: CacheOptions,
): Promise<void> {
try {
const redis = getCacheRedis();
const fullPattern = `${options?.prefix ?? DEFAULT_PREFIX}:${pattern}`;
const keys = await redis.keys(fullPattern);
if (keys.length > 0) {
await redis.del(keys);
}
} catch {
// Silently fail
}
}
export async function closeCacheRedis(): Promise<void> {
if (redis) {
await redis.quit();
redis = null;
}
}

View File

@@ -0,0 +1,61 @@
import { get, set, CacheOptions } from "./cache";
// Cache TTLs in seconds
const TTL = {
user: 300,
subscription: 60,
dashboard: 30,
blog: 3600,
} as const;
export async function getCachedUser<T>(
userId: string,
fetchFn: () => Promise<T>,
options?: CacheOptions,
): Promise<T> {
const cached = await get<T>(`user:${userId}`, { ttl: TTL.user, ...options });
if (cached) return cached;
const data = await fetchFn();
await set(`user:${userId}`, data, { ttl: TTL.user, ...options });
return data;
}
export async function getCachedSubscription<T>(
userId: string,
fetchFn: () => Promise<T>,
options?: CacheOptions,
): Promise<T> {
const cached = await get<T>(`sub:${userId}`, { ttl: TTL.subscription, ...options });
if (cached) return cached;
const data = await fetchFn();
await set(`sub:${userId}`, data, { ttl: TTL.subscription, ...options });
return data;
}
export async function getCachedDashboard<T>(
userId: string,
fetchFn: () => Promise<T>,
options?: CacheOptions,
): Promise<T> {
const cached = await get<T>(`dash:${userId}`, { ttl: TTL.dashboard, ...options });
if (cached) return cached;
const data = await fetchFn();
await set(`dash:${userId}`, data, { ttl: TTL.dashboard, ...options });
return data;
}
export async function getCachedBlog<T>(
slug: string,
fetchFn: () => Promise<T>,
options?: CacheOptions,
): Promise<T> {
const cached = await get<T>(`blog:${slug}`, { ttl: TTL.blog, ...options });
if (cached) return cached;
const data = await fetchFn();
await set(`blog:${slug}`, data, { ttl: TTL.blog, ...options });
return data;
}

75
web/src/server/lib/env.ts Normal file
View File

@@ -0,0 +1,75 @@
import { object, string, optional, parse, safeParse } from "valibot";
const envSchema = object({
// Database
DATABASE_URL: string(),
DATABASE_AUTH_TOKEN: optional(string()),
// Server
PORT: optional(string()),
NODE_ENV: optional(string()),
LOG_LEVEL: optional(string()),
APP_URL: optional(string()),
// Auth
JWT_SECRET: string(),
SESSION_SECRET: optional(string()),
// Clerk
CLERK_SECRET_KEY: string(),
VITE_CLERK_PUBLISHABLE_KEY: string(),
// Stripe
STRIPE_SECRET_KEY: string(),
STRIPE_WEBHOOK_SECRET: string(),
// Redis (for BullMQ)
REDIS_URL: optional(string()),
// Sentry
VITE_SENTRY_DSN: optional(string()),
// Email
RESEND_API_KEY: optional(string()),
// Push
FCM_PROJECT_ID: optional(string()),
FCM_CLIENT_EMAIL: optional(string()),
FCM_PRIVATE_KEY: optional(string()),
// SMS
TWILIO_ACCOUNT_SID: optional(string()),
TWILIO_AUTH_TOKEN: optional(string()),
TWILIO_MESSAGING_SERVICE_SID: optional(string()),
// External APIs
HIBP_API_KEY: optional(string()),
SECURITYTRAILS_API_KEY: optional(string()),
CENSYS_API_ID: optional(string()),
CENSYS_API_SECRET: optional(string()),
SHODAN_API_KEY: optional(string()),
// WebSocket
WS_PORT: optional(string()),
});
export function validateEnv() {
const result = safeParse(envSchema, {
...process.env,
});
if (!result.success) {
const missingKeys = result.issues
.map((issue) => issue.path?.[0]?.key as string | undefined)
.filter((k): k is string => k !== undefined);
console.error("Environment validation failed:");
console.error("Missing required variables:", missingKeys.join(", "));
console.error("\nPlease check .env.example for all required variables.");
process.exit(1);
}
return parse(envSchema, { ...process.env });
}
export const env = validateEnv();

View File

@@ -0,0 +1,36 @@
import pino from "pino";
const isProduction = process.env.NODE_ENV === "production";
const logger = pino({
level: process.env.LOG_LEVEL ?? (isProduction ? "info" : "debug"),
transport: isProduction
? undefined
: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:yyyy-mm-dd HH:MM:ss",
ignore: "pid,hostname",
},
},
base: {
app: "kordant",
},
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"req.headers.x-api-key",
"res.headers.set-cookie",
"password",
"token",
"sessionToken",
"secret",
],
censor: "[REDACTED]",
},
});
export const child = (bindings: pino.Bindings) => logger.child(bindings);
export default logger;

View File

@@ -0,0 +1,82 @@
import { Redis } from "ioredis";
import { TRPCError } from "@trpc/server";
let redis: Redis | null = null;
export function getRateLimitRedis(): Redis {
if (!redis) {
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
throw new Error("REDIS_URL environment variable is required for rate limiting");
}
redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
lazyConnect: true,
});
}
return redis;
}
export type RateLimitTier = {
limit: number;
windowMs: number;
};
export const rateLimitTiers: Record<string, RateLimitTier> = {
public: { limit: 5, windowMs: 60_000 },
authenticated: { limit: 100, windowMs: 60_000 },
sensitive: { limit: 3, windowMs: 3_600_000 },
admin: { limit: 50, windowMs: 60_000 },
websocket: { limit: 1, windowMs: 60_000 },
websocketReconnect: { limit: 5, windowMs: 60_000 },
};
export async function checkRateLimit(
identifier: string,
tier: keyof typeof rateLimitTiers,
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const { limit, windowMs } = rateLimitTiers[tier];
const redis = getRateLimitRedis();
const key = `ratelimit:${tier}:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
// Use Redis sorted set for sliding window
await redis.zremrangebyscore(key, 0, windowStart);
const count = await redis.zcard(key);
if (count >= limit) {
const oldest = await redis.zrange(key, 0, 0, "WITHSCORES");
const resetAt = oldest && oldest.length > 1 ? Number(oldest[1]) + windowMs : now + windowMs;
return { allowed: false, remaining: 0, resetAt };
}
await redis.zadd(key, now, `${now}`);
await redis.expire(key, Math.ceil(windowMs / 1000) + 1);
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}
export async function checkRateLimitOrThrow(
identifier: string,
tier: keyof typeof rateLimitTiers,
): Promise<void> {
const result = await checkRateLimit(identifier, tier);
if (!result.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Retry after ${Math.ceil((result.resetAt - Date.now()) / 1000)}s`,
});
}
}
export async function closeRateLimitRedis(): Promise<void> {
if (redis) {
await redis.quit();
redis = null;
}
}

View File

@@ -0,0 +1,25 @@
import { type RequestMiddleware } from "@solidjs/start/middleware";
import logger from "~/server/lib/logger";
let requestIdCounter = 0;
export const requestLogger: RequestMiddleware = async (event) => {
const start = Date.now();
const requestId = `${Date.now()}-${++requestIdCounter}`;
const childLogger = logger.child({
requestId,
method: event.request.method,
url: event.request.url,
ip: event.clientAddress,
});
childLogger.debug("request:start");
// Add request ID to response headers
event.response.headers.set("X-Request-ID", requestId);
childLogger.info({
duration: Date.now() - start,
}, "request:complete");
};

View File

@@ -0,0 +1,6 @@
function migrate() {
return Promise.resolve();
}
export { migrate };
export default { migrate };

View File

@@ -0,0 +1,23 @@
function drizzle() {
return {
select: () => ({
from: () => ({
where: () => ({ limit: () => Promise.resolve([]) }),
}),
}),
insert: () => ({
values: () => ({ returning: () => Promise.resolve([{ id: "mock-id" }]) }),
}),
update: () => ({
set: () => ({
where: () => ({ returning: () => Promise.resolve([{ id: "mock-id" }]) }),
}),
}),
delete: () => ({
where: () => ({ returning: () => Promise.resolve([]) }),
}),
};
}
export { drizzle };
export default { drizzle };

View File

@@ -0,0 +1,95 @@
// drizzle-orm/sqlite-core mock - captures column info
const tableRegistry = new Map();
function createColumn(name) {
let self;
const handler = {
get(target, prop) {
if (prop === 'name') return name;
if (prop === '_isColumn') return true;
if (prop === Symbol.toStringTag) return 'Object';
return function() { return self; };
},
apply() { return self; },
};
self = new Proxy(function colFn() { return self; }, handler);
return self;
}
const allColumns = [];
function sqliteTable(tableName, schema, indexesFn) {
// Collect columns from schema object
const columns = [];
if (schema && typeof schema === "object") {
for (const key of Object.keys(schema)) {
const col = schema[key];
if (col && typeof col === "function" && col.name !== undefined) {
columns.push({ name: col.name });
}
}
}
// Collect indexes from third argument
const indexes = [];
if (indexesFn && typeof indexesFn === "function") {
const idxDefs = indexesFn({});
if (idxDefs && typeof idxDefs === "object") {
for (const key of Object.keys(idxDefs)) {
indexes.push({ name: key });
}
}
}
const table = {
drizzleName: tableName,
_columns: columns,
_indexes: indexes,
};
tableRegistry.set(tableName, table);
return table;
}
function textFn(name) {
const col = createColumn(name);
allColumns.push(col);
return col;
}
function integerFn(name) {
const col = createColumn(name);
allColumns.push(col);
return col;
}
function realFn(name) {
const col = createColumn(name);
allColumns.push(col);
return col;
}
function createChainable() {
let self;
self = new Proxy(function fn() { return self; }, {
get() { return self; },
apply() { return self; },
});
return self;
}
const uniqueIndex = createChainable();
const index = createChainable();
const pgTable = createChainable();
function getTableConfig(table) {
const name = table?.drizzleName || "";
const registered = tableRegistry.get(name);
return {
name,
schema: undefined,
columns: registered?._columns || [],
indexes: registered?._indexes || [],
};
}
export { sqliteTable, pgTable, textFn as text, integerFn as integer, realFn as real, uniqueIndex, index, getTableConfig };

View File

@@ -0,0 +1,28 @@
// drizzle-orm mock - chainable proxies
function createChainable() {
return new Proxy(function() {}, {
apply() { return createChainable(); },
get() { return createChainable(); },
});
}
const eq = createChainable();
const and = createChainable();
const or = createChainable();
const not = createChainable();
const inArray = createChainable();
const gte = createChainable();
const lte = createChainable();
const gt = createChainable();
const lt = createChainable();
const like = createChainable();
const ilike = createChainable();
const isNull = createChainable();
const isNotNull = createChainable();
const desc = createChainable();
const asc = createChainable();
const count = createChainable();
const sql = createChainable();
const relations = createChainable();
export { eq, and, or, not, inArray, gte, lte, gt, lt, like, ilike, isNull, isNotNull, desc, asc, count, sql, relations };

View File

@@ -0,0 +1,8 @@
function createClient() {
return {
close() {},
};
}
export { createClient };
export default { createClient };

15
web/test/__mocks__/ws.js Normal file
View File

@@ -0,0 +1,15 @@
const WebSocket = {
OPEN: 1,
CONNECTING: 0,
CLOSING: 2,
CLOSED: 3,
};
class MockWebSocketServer {
clients = new Set();
on() {}
close(cb) { cb?.(); }
}
export { MockWebSocketServer as WebSocketServer, WebSocket };
export default { WebSocketServer: MockWebSocketServer, WebSocket };

92
web/test/setup.ts Normal file
View File

@@ -0,0 +1,92 @@
import { vi } from "vitest";
// Mock ws module - ESM interop issue with WebSocket named export
vi.mock("ws", () => {
class MockWebSocketServer {
clients = new Set();
on() {}
close(cb) { cb?.(); }
}
const WebSocket = {
OPEN: 1,
CONNECTING: 0,
CLOSING: 2,
CLOSED: 3,
};
return {
WebSocketServer: MockWebSocketServer,
WebSocket,
};
});
// Mock three module - WebGL not available in jsdom
vi.mock("three", () => {
class MockWebGLRenderer {
domElement = document.createElement("canvas");
setSize() {}
setPixelRatio() {}
setClearColor() {}
render() {}
dispose() {}
}
class MockScene {
add() {}
}
class MockPerspectiveCamera {
position = { z: 0 };
updateProjectionMatrix() {}
}
class MockPlaneGeometry {
attributes = { position: { count: 1 } };
computeVertexNormals() {}
dispose() {}
setAttribute() {}
}
class MockBufferAttribute {
constructor() {}
}
class MockShaderMaterial {
dispose() {}
}
class MockMesh {
rotation = { set() {} };
scale = { set() {}, multiplyScalar() {} };
position = { y: 0 };
}
class MockVector3 {
constructor() {}
}
class MockVector4 {
constructor() {}
}
class MockTimer {
update() {}
getDelta() { return 0.016; }
getElapsed() { return 0; }
}
return {
WebGLRenderer: MockWebGLRenderer,
Scene: MockScene,
PerspectiveCamera: MockPerspectiveCamera,
PlaneGeometry: MockPlaneGeometry,
BufferAttribute: MockBufferAttribute,
ShaderMaterial: MockShaderMaterial,
Mesh: MockMesh,
Vector3: MockVector3,
Vector4: MockVector4,
Timer: MockTimer,
DoubleSide: 2,
};
});

View File

@@ -1,15 +1,59 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
import { readFileSync } from "fs";
import solid from "vite-plugin-solid";
function loadEnvFile(filePath: string): Record<string, string> {
try {
const content = readFileSync(filePath, "utf-8");
const env: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
} catch {
return {};
}
}
const env = { ...loadEnvFile(".env"), ...loadEnvFile(".env.local") };
const mocksDir = resolve(__dirname, "./test/__mocks__");
export default defineConfig({
plugins: [solid()],
test: {
environment: "jsdom",
},
resolve: {
alias: {
"~": resolve(__dirname, "./src"),
setupFiles: ["./test/setup.ts"],
exclude: ["**/node_modules/**", "**/e2e/**", "**/dist/**"],
env: {
RESEND_API_KEY: env.RESEND_API_KEY ?? "",
STRIPE_SECRET_KEY: env.STRIPE_SECRET_KEY ?? "",
STRIPE_WEBHOOK_SECRET: env.STRIPE_WEBHOOK_SECRET ?? "",
STRIPE_PRICE_BASIC: env.STRIPE_PRICE_BASIC ?? "",
STRIPE_PRICE_PLUS: env.STRIPE_PRICE_PLUS ?? "",
STRIPE_PRICE_PREMIUM: env.STRIPE_PRICE_PREMIUM ?? "",
OPENAI_API_KEY: env.OPENAI_API_KEY ?? "",
JWT_SECRET: env.JWT_SECRET ?? "test-secret-for-testing",
},
},
resolve: {
alias: [
{ find: "~", replacement: resolve(__dirname, "./src") },
{ find: /^ws$/, replacement: resolve(mocksDir, "ws.js") },
{ find: /^@libsql\/client$/, replacement: resolve(mocksDir, "libsql.js") },
{ find: /^drizzle-orm\/libsql\/migrator$/, replacement: resolve(mocksDir, "drizzle-orm-libsql-migrator.js") },
{ find: /^drizzle-orm\/libsql$/, replacement: resolve(mocksDir, "drizzle-orm-libsql.js") },
{ find: /^drizzle-orm\/sqlite-core$/, replacement: resolve(mocksDir, "drizzle-orm-sqlite-core.js") },
{ find: /^drizzle-orm$/, replacement: resolve(mocksDir, "drizzle-orm.js") },
],
},
});