feat: comprehensive boilerplate improvements

Security & Stability:
- Add Next.js 16 proxy.ts for BetterAuth cookie-based auth protection
- Add rate limiting for API routes (src/lib/rate-limit.ts)
- Add Zod validation for chat API request bodies
- Add session auth check to chat and diagnostics endpoints
- Add security headers in next.config.ts (CSP, X-Frame-Options, etc.)
- Add file upload validation and sanitization in storage.ts

Core UX Components:
- Add error boundaries (error.tsx, not-found.tsx, chat/error.tsx)
- Add loading states (skeleton.tsx, spinner.tsx, loading.tsx files)
- Add toast notifications with Sonner
- Add form components (input.tsx, textarea.tsx, label.tsx)
- Add database indexes for performance (schema.ts)
- Enhance chat UX: timestamps, copy-to-clipboard, thinking indicator,
  error display, localStorage message persistence

Polish & Accessibility:
- Add Open Graph and Twitter card metadata
- Add JSON-LD structured data for SEO
- Add sitemap.ts, robots.ts, manifest.ts
- Add skip-to-content link and ARIA labels in site-header
- Enable profile page quick action buttons with dialogs
- Update Next.js 15 references to Next.js 16

Developer Experience:
- Add GitHub Actions CI workflow (lint, typecheck, build)
- Add Prettier configuration (.prettierrc, .prettierignore)
- Add .nvmrc pinning Node 20
- Add ESLint rules: import/order, react-hooks/exhaustive-deps
- Add stricter TypeScript settings (exactOptionalPropertyTypes,
  noImplicitOverride)
- Add interactive setup script (scripts/setup.ts)
- Add session utility functions (src/lib/session.ts)

All changes mirrored to create-agentic-app/template/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Leon van Zyl
2025-11-30 14:46:15 +02:00
parent 1121258238
commit a3a151c67a
125 changed files with 5088 additions and 493 deletions

View File

@@ -0,0 +1,117 @@
import { z } from "zod";
/**
* Server-side environment variables schema.
* These variables are only available on the server.
*/
const serverEnvSchema = z.object({
// Database
POSTGRES_URL: z.string().url("Invalid database URL"),
// Authentication
BETTER_AUTH_SECRET: z
.string()
.min(32, "BETTER_AUTH_SECRET must be at least 32 characters"),
// OAuth
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// AI
OPENROUTER_API_KEY: z.string().optional(),
OPENROUTER_MODEL: z.string().default("openai/gpt-5-mini"),
// Storage
BLOB_READ_WRITE_TOKEN: z.string().optional(),
// App
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
});
/**
* Client-side environment variables schema.
* These variables are exposed to the browser via NEXT_PUBLIC_ prefix.
*/
const clientEnvSchema = z.object({
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
});
export type ServerEnv = z.infer<typeof serverEnvSchema>;
export type ClientEnv = z.infer<typeof clientEnvSchema>;
/**
* Validates and returns server-side environment variables.
* Throws an error if validation fails.
*/
export function getServerEnv(): ServerEnv {
const parsed = serverEnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error(
"Invalid server environment variables:",
parsed.error.flatten().fieldErrors
);
throw new Error("Invalid server environment variables");
}
return parsed.data;
}
/**
* Validates and returns client-side environment variables.
* Throws an error if validation fails.
*/
export function getClientEnv(): ClientEnv {
const parsed = clientEnvSchema.safeParse({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});
if (!parsed.success) {
console.error(
"Invalid client environment variables:",
parsed.error.flatten().fieldErrors
);
throw new Error("Invalid client environment variables");
}
return parsed.data;
}
/**
* Checks if required environment variables are set.
* Logs warnings for missing optional variables.
*/
export function checkEnv(): void {
const warnings: string[] = [];
// Check required variables
if (!process.env.POSTGRES_URL) {
throw new Error("POSTGRES_URL is required");
}
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is required");
}
// Check optional variables and warn
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
warnings.push("Google OAuth is not configured. Social login will be disabled.");
}
if (!process.env.OPENROUTER_API_KEY) {
warnings.push("OPENROUTER_API_KEY is not set. AI chat will not work.");
}
if (!process.env.BLOB_READ_WRITE_TOKEN) {
warnings.push("BLOB_READ_WRITE_TOKEN is not set. Using local storage for file uploads.");
}
// Log warnings in development
if (process.env.NODE_ENV === "development" && warnings.length > 0) {
console.warn("\n⚠ Environment warnings:");
warnings.forEach((w) => console.warn(` - ${w}`));
console.warn("");
}
}

View File

@@ -0,0 +1,157 @@
/**
* Simple in-memory rate limiter for API routes.
* For production, consider using a Redis-based solution.
*/
interface RateLimitConfig {
/** Number of requests allowed in the window */
limit: number;
/** Time window in milliseconds */
windowMs: number;
}
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
/**
* Default rate limit configurations for different endpoints.
*/
export const rateLimitConfigs = {
// AI chat endpoint - more restrictive
chat: {
limit: 20,
windowMs: 60 * 1000, // 20 requests per minute
},
// Auth endpoints
auth: {
limit: 10,
windowMs: 60 * 1000, // 10 requests per minute
},
// General API
api: {
limit: 100,
windowMs: 60 * 1000, // 100 requests per minute
},
} as const;
/**
* Check if a request should be rate limited.
*
* @param key - Unique identifier for the client (e.g., IP address, user ID)
* @param config - Rate limit configuration
* @returns Object with allowed status and remaining requests
*/
export function checkRateLimit(
key: string,
config: RateLimitConfig
): {
allowed: boolean;
remaining: number;
resetTime: number;
} {
const now = Date.now();
const entry = rateLimitStore.get(key);
// Clean up expired entries periodically
if (Math.random() < 0.01) {
cleanupExpiredEntries();
}
if (!entry || now > entry.resetTime) {
// Create new entry
const newEntry: RateLimitEntry = {
count: 1,
resetTime: now + config.windowMs,
};
rateLimitStore.set(key, newEntry);
return {
allowed: true,
remaining: config.limit - 1,
resetTime: newEntry.resetTime,
};
}
if (entry.count >= config.limit) {
return {
allowed: false,
remaining: 0,
resetTime: entry.resetTime,
};
}
// Increment count
entry.count++;
return {
allowed: true,
remaining: config.limit - entry.count,
resetTime: entry.resetTime,
};
}
/**
* Create a rate limit response with appropriate headers.
*/
export function createRateLimitResponse(resetTime: number): Response {
const retryAfter = Math.ceil((resetTime - Date.now()) / 1000);
return new Response(
JSON.stringify({
error: "Too Many Requests",
message: "Rate limit exceeded. Please try again later.",
retryAfter,
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfter),
"X-RateLimit-Reset": String(Math.ceil(resetTime / 1000)),
},
}
);
}
/**
* Get the client identifier from a request.
* Uses IP address or forwarded header.
*/
export function getClientIdentifier(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const ip = forwarded.split(",")[0];
if (ip) {
return ip.trim();
}
}
// Fallback to a hash of user-agent if no IP available
const userAgent = request.headers.get("user-agent") || "unknown";
return `ua-${hashString(userAgent)}`;
}
/**
* Simple string hash for fallback identifier.
*/
function hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
/**
* Clean up expired rate limit entries.
*/
function cleanupExpiredEntries(): void {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(key);
}
}
}

View File

@@ -1,52 +1,70 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const user = pgTable(
"user",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("user_email_idx").on(table.email)]
);
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("session_user_id_idx").on(table.userId),
index("session_token_idx").on(table.token),
]
);
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [
index("account_user_id_idx").on(table.userId),
index("account_provider_account_idx").on(table.providerId, table.accountId),
]
);
export const verification = pgTable("verification", {
id: text("id").primaryKey(),

View File

@@ -0,0 +1,48 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
/**
* Protected routes that require authentication.
* These are also configured in src/proxy.ts for optimistic redirects.
*/
export const protectedRoutes = ["/chat", "/dashboard", "/profile"];
/**
* Checks if the current request is authenticated.
* Should be called in Server Components for protected routes.
*
* @returns The session object if authenticated
* @throws Redirects to home page if not authenticated
*/
export async function requireAuth() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/");
}
return session;
}
/**
* Gets the current session without requiring authentication.
* Returns null if not authenticated.
*
* @returns The session object or null
*/
export async function getOptionalSession() {
return await auth.api.getSession({ headers: await headers() });
}
/**
* Checks if a given path is a protected route.
*
* @param path - The path to check
* @returns True if the path requires authentication
*/
export function isProtectedRoute(path: string): boolean {
return protectedRoutes.some(
(route) => path === route || path.startsWith(`${route}/`)
);
}

View File

@@ -1,7 +1,7 @@
import { put, del } from "@vercel/blob";
import { existsSync } from "fs";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
import { put, del } from "@vercel/blob";
/**
* Result from uploading a file to storage
@@ -11,14 +11,123 @@ export interface StorageResult {
pathname: string; // Path/key of the stored file
}
/**
* Storage configuration
*/
export interface StorageConfig {
/** Maximum file size in bytes (default: 5MB) */
maxSize?: number;
/** Allowed MIME types (default: images and documents) */
allowedTypes?: string[];
}
/**
* Default storage configuration
*/
const DEFAULT_CONFIG: Required<StorageConfig> = {
maxSize: 5 * 1024 * 1024, // 5MB
allowedTypes: [
// Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
// Documents
"application/pdf",
"text/plain",
"text/csv",
"application/json",
],
};
/**
* Allowed file extensions mapped from MIME types
*/
const ALLOWED_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".gif",
".webp",
".svg",
".pdf",
".txt",
".csv",
".json",
]);
/**
* Sanitize a filename by removing dangerous characters and path traversal attempts
*/
export function sanitizeFilename(filename: string): string {
// Remove path components (prevent directory traversal)
const basename = filename.split(/[/\\]/).pop() || filename;
// Remove or replace dangerous characters
const sanitized = basename
.replace(/[<>:"|?*\x00-\x1f]/g, "") // Remove dangerous chars
.replace(/\.{2,}/g, ".") // Collapse multiple dots
.replace(/^\.+/, "") // Remove leading dots
.trim();
// Ensure filename is not empty
if (!sanitized || sanitized.length === 0) {
throw new Error("Invalid filename");
}
// Limit filename length
if (sanitized.length > 255) {
const ext = sanitized.slice(sanitized.lastIndexOf("."));
const name = sanitized.slice(0, 255 - ext.length);
return name + ext;
}
return sanitized;
}
/**
* Validate file for upload
*/
export function validateFile(
buffer: Buffer,
filename: string,
config: StorageConfig = {}
): { valid: true } | { valid: false; error: string } {
const { maxSize } = { ...DEFAULT_CONFIG, ...config };
// Check file size
if (buffer.length > maxSize) {
return {
valid: false,
error: `File too large. Maximum size is ${Math.round(maxSize / 1024 / 1024)}MB`,
};
}
// Check file extension
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) {
return {
valid: false,
error: `File type not allowed. Allowed extensions: ${Array.from(ALLOWED_EXTENSIONS).join(", ")}`,
};
}
// Optionally check MIME type if provided
// Note: For full MIME type validation, consider using a library like 'file-type'
return { valid: true };
}
/**
* Uploads a file to storage (Vercel Blob or local filesystem)
*
*
* @param buffer - File contents as a Buffer
* @param filename - Name of the file (e.g., "image.png")
* @param folder - Optional folder/prefix (e.g., "avatars")
* @param config - Optional storage configuration
* @returns StorageResult with url and pathname
*
*
* @example
* ```ts
* const result = await upload(fileBuffer, "avatar.png", "avatars");
@@ -28,13 +137,23 @@ export interface StorageResult {
export async function upload(
buffer: Buffer,
filename: string,
folder?: string
folder?: string,
config?: StorageConfig
): Promise<StorageResult> {
// Sanitize filename
const sanitizedFilename = sanitizeFilename(filename);
// Validate file
const validation = validateFile(buffer, sanitizedFilename, config);
if (!validation.valid) {
throw new Error(validation.error);
}
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
if (hasVercelBlob) {
// Use Vercel Blob storage
const pathname = folder ? `${folder}/${filename}` : filename;
const pathname = folder ? `${folder}/${sanitizedFilename}` : sanitizedFilename;
const blob = await put(pathname, buffer, {
access: "public",
});
@@ -54,11 +173,11 @@ export async function upload(
}
// Write the file
const filepath = join(targetDir, filename);
const filepath = join(targetDir, sanitizedFilename);
await writeFile(filepath, buffer);
// Return local URL
const pathname = folder ? `${folder}/${filename}` : filename;
const pathname = folder ? `${folder}/${sanitizedFilename}` : sanitizedFilename;
const url = `/uploads/${pathname}`;
return {