feat: add better auth sqlite runtime and server-side session flow

This commit is contained in:
Poyraz
2026-07-10 22:07:37 +03:00
parent 3504a02229
commit 57aab2932e
41 changed files with 4907 additions and 143 deletions
+95
View File
@@ -0,0 +1,95 @@
import "server-only";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { getServerConfig } from "@/server/config";
import { getSqliteConnection } from "@/server/db/client";
import * as schema from "@/server/db/schema";
import {
completeFirstFreelancerSetup,
recordAuthAuditEvent,
reserveFirstFreelancerSetup,
} from "@/server/auth/setup";
const config = getServerConfig();
export const auth = betterAuth({
appName: "Neta",
baseURL: config.appUrl,
trustedOrigins: config.trustedOrigins,
secret: config.betterAuthSecret,
database: drizzleAdapter(getSqliteConnection().db, {
provider: "sqlite",
schema,
transaction: true,
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
maxPasswordLength: 128,
requireEmailVerification: false,
},
rateLimit: {
enabled: true,
window: 60,
max: 60,
customRules: {
"/sign-in/email": {
window: 60,
max: 10,
},
"/sign-up/email": {
window: 300,
max: 3,
},
},
},
advanced: {
useSecureCookies: config.nodeEnv === "production",
cookiePrefix: "neta",
defaultCookieAttributes: {
httpOnly: true,
sameSite: "lax",
secure: config.nodeEnv === "production",
path: "/",
},
},
databaseHooks: {
user: {
create: {
before: async (user) => {
const isReserved = await reserveFirstFreelancerSetup(user.email);
return isReserved;
},
after: async (user) => {
await completeFirstFreelancerSetup(user);
},
},
},
session: {
create: {
after: async (session) => {
await recordAuthAuditEvent({
type: "login_succeeded",
authUserId: session.userId,
metadata: { source: "session_create" },
});
},
},
delete: {
after: async (session) => {
await recordAuthAuditEvent({
type: "logout_succeeded",
authUserId: session.userId,
metadata: { source: "session_delete" },
});
},
},
},
},
plugins: [nextCookies()],
});
export type Auth = typeof auth;
+45
View File
@@ -0,0 +1,45 @@
import "server-only";
import type { SessionContext } from "@/server/auth/session";
import type { UserRole } from "@/server/auth/types";
export class AuthorizationError extends Error {
constructor(
message = "Bu işlem için yetkiniz yok.",
public readonly code: "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" = "FORBIDDEN",
) {
super(message);
this.name = "AuthorizationError";
}
}
export function assertRole(context: SessionContext | null, allowedRoles: readonly UserRole[]): void {
if (!context) {
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
}
if (!allowedRoles.includes(context.profile.role)) {
throw new AuthorizationError();
}
}
export function assertSameOwner(context: SessionContext | null, ownerAuthUserId: string): void {
if (!context) {
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
}
if (context.user.id !== ownerAuthUserId) {
throw new AuthorizationError("Kaynak bulunamadı.", "NOT_FOUND");
}
}
export function assertEnabledUser(context: SessionContext | null): void {
if (!context) {
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
}
if (context.profile.disabled) {
throw new AuthorizationError();
}
}
+99
View File
@@ -0,0 +1,99 @@
import "server-only";
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import { auth } from "@/server/auth/auth";
import type { UserRole } from "@/server/auth/types";
import { getSqliteConnection } from "@/server/db/client";
import { appProfiles } from "@/server/db/schema";
type BetterAuthSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
export type SessionContext = {
session: BetterAuthSession["session"];
user: BetterAuthSession["user"];
profile: {
id: number;
authUserId: string;
email: string;
displayName: string;
role: UserRole;
disabled: boolean;
};
};
export const getSessionContext = cache(async (): Promise<SessionContext | null> => {
const session = await auth.api.getSession({
headers: await headers(),
query: {
disableCookieCache: true,
},
});
if (!session) {
return null;
}
const profile = getProfileByAuthUserId(session.user.id);
if (!profile || profile.disabled || profile.authUserId !== session.user.id) {
return null;
}
return {
session: session.session,
user: session.user,
profile,
};
});
export async function requireSession(): Promise<SessionContext> {
const context = await getSessionContext();
if (!context) {
redirect("/login");
}
return context;
}
export async function requireFreelancer(): Promise<SessionContext> {
const context = await requireSession();
if (context.profile.role !== "freelancer") {
redirect("/portal");
}
return context;
}
export async function requireClientUser(): Promise<SessionContext> {
const context = await requireSession();
if (context.profile.role !== "client") {
redirect("/");
}
return context;
}
export function getProfileByAuthUserId(authUserId: string): SessionContext["profile"] | null {
const { db } = getSqliteConnection();
const [profile] = db
.select({
id: appProfiles.id,
authUserId: appProfiles.authUserId,
email: appProfiles.email,
displayName: appProfiles.displayName,
role: appProfiles.role,
disabled: appProfiles.disabled,
})
.from(appProfiles)
.where(eq(appProfiles.authUserId, authUserId))
.limit(1)
.all();
return profile ?? null;
}
+207
View File
@@ -0,0 +1,207 @@
import "server-only";
import { count, eq } from "drizzle-orm";
import { getSqliteConnection } from "@/server/db/client";
import { appProfiles, appSetupState, authAuditEvents } from "@/server/db/schema";
import type { AuthAuditEventType } from "@/server/auth/types";
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
const FIRST_FREELANCER_SETUP_KEY = "first_freelancer";
const SETUP_LOCK_TTL_MS = 10 * 60 * 1000;
export type FirstFreelancerSetupState = {
available: boolean;
locked: boolean;
errorMessage?: string;
};
export async function getFirstFreelancerSetupState(): Promise<FirstFreelancerSetupState> {
try {
return readFirstFreelancerSetupState();
} catch (error) {
return {
available: false,
locked: false,
errorMessage:
error instanceof Error
? error.message
: "İlk kurulum durumu okunamadı.",
};
}
}
export function readFirstFreelancerSetupState(): FirstFreelancerSetupState {
const { db } = getSqliteConnection();
const [{ value: freelancerCount }] = db
.select({ value: count() })
.from(appProfiles)
.where(eq(appProfiles.role, "freelancer"))
.all();
if (freelancerCount > 0) {
return { available: false, locked: false };
}
const [setupState] = db
.select()
.from(appSetupState)
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
.limit(1)
.all();
if (!setupState) {
return { available: true, locked: false };
}
if (setupState.status === "completed") {
return { available: false, locked: false };
}
const lockedAt = setupState.lockedAt?.getTime() ?? 0;
const isStale = Date.now() - lockedAt > SETUP_LOCK_TTL_MS;
return {
available: isStale,
locked: !isStale,
errorMessage: isStale ? undefined : "İlk kurulum şu anda başka bir istek tarafından işleniyor.",
};
}
export async function reserveFirstFreelancerSetup(email: string): Promise<boolean> {
const normalizedEmail = normalizeAuthEmail(email);
const { db } = getSqliteConnection();
return db.transaction((tx) => {
const [{ value: freelancerCount }] = tx
.select({ value: count() })
.from(appProfiles)
.where(eq(appProfiles.role, "freelancer"))
.all();
if (freelancerCount > 0) {
return false;
}
const [setupState] = tx
.select()
.from(appSetupState)
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
.limit(1)
.all();
const now = new Date();
if (setupState?.status === "completed") {
return false;
}
if (setupState?.status === "pending") {
const lockedAt = setupState.lockedAt?.getTime() ?? 0;
if (Date.now() - lockedAt <= SETUP_LOCK_TTL_MS) {
return false;
}
}
tx.insert(appSetupState)
.values({
key: FIRST_FREELANCER_SETUP_KEY,
status: "pending",
lockedBy: normalizedEmail,
lockedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: appSetupState.key,
set: {
status: "pending",
lockedBy: normalizedEmail,
lockedAt: now,
updatedAt: now,
},
})
.run();
tx.insert(authAuditEvents)
.values({
type: "setup_started",
email: normalizedEmail,
metadata: { source: "better_auth_user_create" },
})
.run();
return true;
});
}
export async function completeFirstFreelancerSetup(user: {
id: string;
email: string;
name?: string | null;
}): Promise<void> {
const normalizedEmail = normalizeAuthEmail(user.email);
const now = new Date();
const { db } = getSqliteConnection();
db.transaction((tx) => {
tx.insert(appProfiles)
.values({
authUserId: user.id,
email: normalizedEmail,
displayName: user.name || getDefaultDisplayName(normalizedEmail),
role: "freelancer",
disabled: false,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing()
.run();
tx.insert(appSetupState)
.values({
key: FIRST_FREELANCER_SETUP_KEY,
status: "completed",
lockedBy: normalizedEmail,
lockedAt: now,
completedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: appSetupState.key,
set: {
status: "completed",
lockedBy: normalizedEmail,
completedAt: now,
updatedAt: now,
},
})
.run();
tx.insert(authAuditEvents)
.values({
type: "setup_completed",
authUserId: user.id,
email: normalizedEmail,
metadata: { role: "freelancer" },
})
.run();
});
}
export async function recordAuthAuditEvent(input: {
type: AuthAuditEventType;
authUserId?: string | null;
email?: string | null;
metadata?: Record<string, unknown> | null;
}): Promise<void> {
const { db } = getSqliteConnection();
db.insert(authAuditEvents)
.values({
type: input.type,
authUserId: input.authUserId ?? null,
email: input.email ? normalizeAuthEmail(input.email) : null,
metadata: input.metadata ?? null,
})
.run();
}
+13
View File
@@ -0,0 +1,13 @@
export const userRoles = ["freelancer", "client"] as const;
export type UserRole = (typeof userRoles)[number];
export type SetupStatus = "pending" | "completed";
export type AuthAuditEventType =
| "setup_started"
| "setup_completed"
| "login_succeeded"
| "login_failed"
| "logout_succeeded";
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod";
export const authCredentialsSchema = z.object({
email: z.email().transform((value) => normalizeAuthEmail(value)),
password: z.string().min(8).max(128),
});
export type AuthCredentials = z.infer<typeof authCredentialsSchema>;
export function parseAuthCredentials(formData: FormData): AuthCredentials {
return authCredentialsSchema.parse({
email: formData.get("email"),
password: formData.get("password"),
});
}
export function normalizeAuthEmail(value: string): string {
return value.trim().toLowerCase();
}
export function getDefaultDisplayName(email: string): string {
return email.split("@")[0] || "Neta Kullanıcısı";
}