This commit is contained in:
jason
2026-04-20 15:49:01 -05:00
parent 381a31d607
commit b98837a72c
46 changed files with 8883 additions and 37 deletions
+29
View File
@@ -0,0 +1,29 @@
import { prisma } from "@/lib/prisma";
export interface AuditInput {
actorId?: string | null;
action: string;
entity: string;
entityId?: string | null;
before?: unknown;
after?: unknown;
ipAddress?: string | null;
}
export async function audit(input: AuditInput): Promise<void> {
try {
await prisma.auditLog.create({
data: {
actorId: input.actorId ?? null,
action: input.action,
entity: input.entity,
entityId: input.entityId ?? null,
before: input.before ? JSON.stringify(input.before) : null,
after: input.after ? JSON.stringify(input.after) : null,
ipAddress: input.ipAddress ?? null,
},
});
} catch (err) {
console.error("[audit] failed to record:", err);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { getSessionUser, type SessionUser } from "@/lib/session";
export async function getCurrentUser(): Promise<SessionUser | null> {
return getSessionUser();
}
export async function requireUser(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) redirect("/login");
return user;
}
export async function requireAdmin(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.role !== "admin") redirect("/");
return user;
}
export async function requireOperator(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.role !== "operator") redirect("/");
return user;
}
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string().min(1),
UPLOAD_DIR: z.string().default("./data/uploads"),
APP_URL: z.string().url().default("http://localhost:3000"),
APP_SECRET: z.string().min(32, "APP_SECRET must be at least 32 chars"),
ADMIN_SESSION_HOURS: z.coerce.number().int().positive().default(8),
OPERATOR_SESSION_HOURS: z.coerce.number().int().positive().default(12),
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(1).optional(),
BOOTSTRAP_ADMIN_NAME: z.string().default("Administrator"),
PIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(5),
PIN_LOCKOUT_MINUTES: z.coerce.number().int().positive().default(15),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export type Env = z.infer<typeof EnvSchema>;
function load(): Env {
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Invalid environment configuration:\n${issues}`);
}
return parsed.data;
}
export const env = load();
+29
View File
@@ -0,0 +1,29 @@
import bcrypt from "bcryptjs";
const ADMIN_ROUNDS = 12;
const PIN_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
if (!password || password.length < 8) {
throw new Error("Password must be at least 8 characters");
}
return bcrypt.hash(password, ADMIN_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function isValidPin(pin: string): boolean {
return /^\d{4}$/.test(pin);
}
export async function hashPin(pin: string): Promise<string> {
if (!isValidPin(pin)) throw new Error("PIN must be exactly 4 digits");
return bcrypt.hash(pin, PIN_ROUNDS);
}
export async function verifyPin(pin: string, hash: string): Promise<boolean> {
if (!isValidPin(pin)) return false;
return bcrypt.compare(pin, hash);
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
declare global {
var __prismaClient: PrismaClient | undefined;
}
export const prisma =
globalThis.__prismaClient ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalThis.__prismaClient = prisma;
}
+14
View File
@@ -0,0 +1,14 @@
import type { NextRequest } from "next/server";
export function clientIp(req: NextRequest | Request): string | null {
const headers = "headers" in req ? req.headers : new Headers();
const fwd = headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]!.trim();
const real = headers.get("x-real-ip");
if (real) return real.trim();
return null;
}
export function userAgent(req: NextRequest | Request): string | null {
return req.headers.get("user-agent");
}
+113
View File
@@ -0,0 +1,113 @@
import { randomBytes, createHash } from "node:crypto";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { env } from "@/lib/env";
export const SESSION_COOKIE = "mrp_session";
export type Role = "admin" | "operator";
export interface SessionUser {
id: string;
role: Role;
name: string;
email: string | null;
}
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function newToken(): { token: string; hash: string } {
const token = randomBytes(32).toString("base64url");
return { token, hash: sha256(token) };
}
function ttlForRole(role: Role): number {
const hours = role === "admin" ? env.ADMIN_SESSION_HOURS : env.OPERATOR_SESSION_HOURS;
return hours * 60 * 60 * 1000;
}
export interface CreateSessionInput {
userId: string;
role: Role;
userAgent?: string | null;
ipAddress?: string | null;
deviceLabel?: string | null;
}
export async function createSession(input: CreateSessionInput): Promise<{ token: string; expiresAt: Date }> {
const { token, hash } = newToken();
const expiresAt = new Date(Date.now() + ttlForRole(input.role));
await prisma.session.create({
data: {
userId: input.userId,
tokenHash: hash,
expiresAt,
userAgent: input.userAgent ?? null,
ipAddress: input.ipAddress ?? null,
deviceLabel: input.deviceLabel ?? null,
},
});
const jar = await cookies();
jar.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
path: "/",
expires: expiresAt,
});
return { token, expiresAt };
}
export async function getSessionUser(): Promise<SessionUser | null> {
const jar = await cookies();
const token = jar.get(SESSION_COOKIE)?.value;
if (!token) return null;
const hash = sha256(token);
const session = await prisma.session.findUnique({
where: { tokenHash: hash },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => {});
return null;
}
if (!session.user.active) return null;
// touch lastSeenAt at most once per minute to limit write load
const stale = Date.now() - session.lastSeenAt.getTime() > 60_000;
if (stale) {
prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => {});
}
return {
id: session.user.id,
role: session.user.role as Role,
name: session.user.name,
email: session.user.email,
};
}
export async function destroyCurrentSession(): Promise<void> {
const jar = await cookies();
const token = jar.get(SESSION_COOKIE)?.value;
if (token) {
await prisma.session.deleteMany({ where: { tokenHash: sha256(token) } });
}
jar.delete(SESSION_COOKIE);
}
export async function purgeExpiredSessions(): Promise<number> {
const result = await prisma.session.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}