Align backend plan with codebase

This commit is contained in:
adilallo
2026-04-04 22:20:02 -06:00
parent fe54390849
commit c8e930552b
36 changed files with 2216 additions and 2 deletions
+16
View File
@@ -0,0 +1,16 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+13
View File
@@ -0,0 +1,13 @@
export function getSessionPepper(): string {
const secret = process.env.SESSION_SECRET;
if (!secret || secret.length < 16) {
throw new Error(
"SESSION_SECRET must be set (min 16 characters) for auth routes.",
);
}
return secret;
}
export function isDatabaseConfigured(): boolean {
return Boolean(process.env.DATABASE_URL?.trim());
}
+17
View File
@@ -0,0 +1,17 @@
import { createHash, randomBytes } from "crypto";
export function sha256Hex(input: string): string {
return createHash("sha256").update(input, "utf8").digest("hex");
}
export function hashOtpCode(code: string, pepper: string): string {
return sha256Hex(`${pepper}:otp:${code}`);
}
export function hashSessionToken(token: string, pepper: string): string {
return sha256Hex(`${pepper}:session:${token}`);
}
export function newSessionToken(): string {
return randomBytes(32).toString("base64url");
}
+24
View File
@@ -0,0 +1,24 @@
import nodemailer from "nodemailer";
import { logger } from "../logger";
export async function sendOtpEmail(to: string, code: string): Promise<void> {
const url = process.env.SMTP_URL;
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(`[dev] OTP for ${to}: ${code}`);
return;
}
throw new Error("SMTP_URL is not configured");
}
const transporter = nodemailer.createTransport(url);
const from = process.env.SMTP_FROM ?? "noreply@localhost";
await transporter.sendMail({
from,
to,
subject: "Your Community Rule sign-in code",
text: `Your sign-in code is: ${code}\n\nIt expires in 10 minutes.`,
});
}
+20
View File
@@ -0,0 +1,20 @@
/**
* In-memory rate limiter (per server instance). Sufficient for small deploys;
* replace with Redis or edge limits if you scale horizontally.
*/
const windows = new Map<string, number>();
export function rateLimitKey(
key: string,
minIntervalMs: number,
): { ok: true } | { ok: false; retryAfterMs: number } {
const now = Date.now();
const last = windows.get(key) ?? 0;
const elapsed = now - last;
if (elapsed < minIntervalMs) {
return { ok: false, retryAfterMs: minIntervalMs - elapsed };
}
windows.set(key, now);
return { ok: true };
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
export function dbUnavailable(): NextResponse {
return NextResponse.json(
{ error: "Database is not configured (DATABASE_URL)." },
{ status: 503 },
);
}
+89
View File
@@ -0,0 +1,89 @@
import { cookies } from "next/headers";
import type { User } from "@prisma/client";
import { prisma } from "./db";
import { getSessionPepper } from "./env";
import { hashSessionToken, newSessionToken } from "./hash";
export const SESSION_COOKIE_NAME = "cr_session";
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
export async function getSessionUser(): Promise<User | null> {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
if (!token) return null;
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return null;
}
const tokenHash = hashSessionToken(token, pepper);
const session = await prisma.session.findUnique({
where: { tokenHash },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
return null;
}
return session.user;
}
export async function createSessionForUser(
userId: string,
): Promise<{ token: string; expiresAt: Date }> {
const pepper = getSessionPepper();
const token = newSessionToken();
const tokenHash = hashSessionToken(token, pepper);
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000);
await prisma.session.create({
data: {
userId,
tokenHash,
expiresAt,
},
});
return { token, expiresAt };
}
export async function setSessionCookie(token: string, expiresAt: Date): Promise<void> {
const store = await cookies();
store.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: expiresAt,
});
}
export async function clearSessionCookie(): Promise<void> {
const store = await cookies();
store.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
});
}
export async function destroySessionFromRequest(): Promise<void> {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
await clearSessionCookie();
if (!token) return;
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return;
}
const tokenHash = hashSessionToken(token, pepper);
await prisma.session.deleteMany({ where: { tokenHash } });
}