148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
import { betterAuth } from "better-auth";
|
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
import { organization } from "better-auth/plugins";
|
|
import { magicLink } from "better-auth/plugins/magic-link";
|
|
import { count, eq } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import * as schema from "@/lib/db/schema";
|
|
|
|
const isProduction = process.env.NODE_ENV === "production";
|
|
|
|
export const auth = betterAuth({
|
|
// -------------------------------------------------------------------------
|
|
// Core
|
|
// -------------------------------------------------------------------------
|
|
secret: process.env.BETTER_AUTH_SECRET!,
|
|
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Database adapter (Drizzle + bun:sqlite)
|
|
// -------------------------------------------------------------------------
|
|
database: drizzleAdapter(db, {
|
|
provider: "sqlite",
|
|
// Keys must match Better Auth's internal model names (singular).
|
|
// usePlural: false (default) → "user", "session", "account", "verification"
|
|
schema: {
|
|
user: schema.users,
|
|
session: schema.sessions,
|
|
account: schema.accounts,
|
|
verification: schema.verifications,
|
|
},
|
|
}),
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Custom user fields
|
|
// -------------------------------------------------------------------------
|
|
user: {
|
|
additionalFields: {
|
|
role: {
|
|
type: "string",
|
|
required: false,
|
|
defaultValue: "moderator",
|
|
input: false, // Not settable by the user directly
|
|
},
|
|
},
|
|
},
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Email + password authentication
|
|
// -------------------------------------------------------------------------
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
requireEmailVerification: false,
|
|
minPasswordLength: 8,
|
|
maxPasswordLength: 128,
|
|
},
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Database hooks — first registered user becomes admin automatically
|
|
// -------------------------------------------------------------------------
|
|
databaseHooks: {
|
|
user: {
|
|
create: {
|
|
after: async (user) => {
|
|
// Count all users; if this is the very first, promote to admin
|
|
const [{ total }] = await db
|
|
.select({ total: count() })
|
|
.from(schema.users);
|
|
|
|
if (total === 1) {
|
|
await db
|
|
.update(schema.users)
|
|
.set({ role: "admin" } as Record<string, unknown>)
|
|
.where(eq(schema.users.id, user.id));
|
|
console.log(`[Auth] First user ${user.id} (${user.email}) promoted to admin`);
|
|
}
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Plugins
|
|
// -------------------------------------------------------------------------
|
|
plugins: [
|
|
// Organization / role support
|
|
organization(),
|
|
|
|
// Magic link — used for invitation acceptance flows
|
|
magicLink({
|
|
expiresIn: 60 * 60, // 1 hour
|
|
disableSignUp: true, // magic links are only for invited users
|
|
sendMagicLink: async ({ email, url, token }) => {
|
|
// Delegate to the application's email module. The email module is
|
|
// responsible for importing and calling Resend (or whichever mailer
|
|
// is configured). We do a dynamic import so that this file does not
|
|
// pull in email dependencies at auth-initialisation time on the edge.
|
|
const { sendMagicLinkEmail } = await import("@/lib/email/index");
|
|
await sendMagicLinkEmail({ email, url, token });
|
|
},
|
|
}),
|
|
],
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Trusted origins — allow env-configured list plus localhost in dev
|
|
// -------------------------------------------------------------------------
|
|
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
|
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",").map((o) => o.trim())
|
|
: ["http://localhost:3000"],
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Cookie / session security
|
|
// -------------------------------------------------------------------------
|
|
advanced: {
|
|
useSecureCookies: isProduction,
|
|
defaultCookieAttributes: {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: "strict",
|
|
path: "/",
|
|
},
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Type helpers for use across the application
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Auth = typeof auth;
|
|
|
|
/** The server-side session type returned by auth.api.getSession */
|
|
export type Session = typeof auth.$Infer.Session.session;
|
|
|
|
/** The user type embedded in every session, with custom additionalFields */
|
|
export type User = typeof auth.$Infer.Session.user & {
|
|
role?: "superadmin" | "admin" | "moderator" | null;
|
|
};
|
|
|
|
type RawSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
|
|
|
|
/** Typed wrapper around auth.api.getSession that includes the role field */
|
|
export async function getAuthSession(
|
|
headers: Headers,
|
|
): Promise<(Omit<RawSession, "user"> & { user: User }) | null> {
|
|
return auth.api.getSession({ headers }) as Promise<
|
|
(Omit<RawSession, "user"> & { user: User }) | null
|
|
>;
|
|
}
|