Align backend plan with codebase
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
async function parseJson<T>(response: Response): Promise<T> {
|
||||
const data = (await response.json()) as T;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchAuthSession(): Promise<{
|
||||
user: { id: string; email: string } | null;
|
||||
}> {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { user: null };
|
||||
}
|
||||
return parseJson(res);
|
||||
}
|
||||
|
||||
export async function requestOtp(email: string): Promise<{ ok: true } | { error: string }> {
|
||||
const res = await fetch("/api/auth/otp/request", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await parseJson<{ error?: string }>(res);
|
||||
if (!res.ok) {
|
||||
return { error: data.error ?? "Request failed" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function verifyOtp(
|
||||
email: string,
|
||||
code: string,
|
||||
): Promise<
|
||||
{ ok: true; user: { id: string; email: string } } | { error: string }
|
||||
> {
|
||||
const res = await fetch("/api/auth/otp/verify", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
const data = await parseJson<{
|
||||
error?: string;
|
||||
user?: { id: string; email: string };
|
||||
}>(res);
|
||||
if (!res.ok || !data.user) {
|
||||
return { error: data.error ?? "Verification failed" };
|
||||
}
|
||||
return { ok: true, user: data.user };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
|
||||
const res = await fetch("/api/drafts/me", { credentials: "include" });
|
||||
if (res.status === 401) return null;
|
||||
if (!res.ok) return null;
|
||||
const data = await parseJson<{ draft: { payload: unknown } | null }>(res);
|
||||
if (!data.draft?.payload || typeof data.draft.payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
return data.draft.payload as CreateFlowState;
|
||||
}
|
||||
|
||||
export async function saveDraftToServer(state: CreateFlowState): Promise<boolean> {
|
||||
const res = await fetch("/api/drafts/me", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ payload: state }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function publishRule(input: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
document: Record<string, unknown>;
|
||||
}): Promise<
|
||||
| { ok: true; id: string; title: string }
|
||||
| { error: string }
|
||||
> {
|
||||
const res = await fetch("/api/rules", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
document: input.document,
|
||||
}),
|
||||
});
|
||||
const data = await parseJson<{
|
||||
error?: string;
|
||||
rule?: { id: string; title: string };
|
||||
}>(res);
|
||||
if (!res.ok || !data.rule) {
|
||||
return { error: data.error ?? "Publish failed" };
|
||||
}
|
||||
return { ok: true, id: data.rule.id, title: data.rule.title };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
Reference in New Issue
Block a user