oof
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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")!);
|
||||
|
||||
@@ -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,
|
||||
|
||||
13
web/src/routes/api/health.ts
Normal file
13
web/src/routes/api/health.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
13
web/src/routes/api/ready.ts
Normal file
13
web/src/routes/api/ready.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
70
web/src/routes/privacy.tsx
Normal file
70
web/src/routes/privacy.tsx
Normal 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;
|
||||
35
web/src/routes/sitemap.xml.ts
Normal file
35
web/src/routes/sitemap.xml.ts
Normal 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
64
web/src/routes/terms.tsx
Normal 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;
|
||||
12
web/src/server/api/routers/api.ts
Normal file
12
web/src/server/api/routers/api.ts
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
51
web/src/server/api/validation.ts
Normal file
51
web/src/server/api/validation.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\//g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
69
web/src/server/health.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
88
web/src/server/lib/cache.ts
Normal file
88
web/src/server/lib/cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
61
web/src/server/lib/cached-queries.ts
Normal file
61
web/src/server/lib/cached-queries.ts
Normal 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
75
web/src/server/lib/env.ts
Normal 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();
|
||||
36
web/src/server/lib/logger.ts
Normal file
36
web/src/server/lib/logger.ts
Normal 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;
|
||||
82
web/src/server/lib/ratelimit.ts
Normal file
82
web/src/server/lib/ratelimit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
web/src/server/lib/request-logger.ts
Normal file
25
web/src/server/lib/request-logger.ts
Normal 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");
|
||||
};
|
||||
Reference in New Issue
Block a user