Files
claudemesh/packages/auth/src/server.ts
Alejandro Gutiérrez 88dca92b55
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
feat(auth): enable postmark email verification for v0.1.0 launch
- switch email provider from resend (unused) to postmark (creds available)
- re-enable requireEmailVerification now that email path works
- env vars POSTMARK_API_KEY + EMAIL_FROM must be set in Coolify
2026-04-05 15:18:52 +01:00

256 lines
7.3 KiB
TypeScript

import { expo } from "@better-auth/expo";
import { passkey } from "@better-auth/passkey";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { betterAuth } from "better-auth/minimal";
import { nextCookies } from "better-auth/next-js";
import {
anonymous,
magicLink,
twoFactor,
organization,
admin,
lastLoginMethod,
} from "better-auth/plugins";
import * as schema from "@turbostarter/db/schema";
import { creditTransaction, customer } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import { EmailTemplate } from "@turbostarter/email";
import { sendEmail } from "@turbostarter/email/server";
import { getLocaleFromRequest } from "@turbostarter/i18n/server";
import { NodeEnv } from "@turbostarter/shared/constants";
import { logger } from "@turbostarter/shared/logger";
import { generateId } from "@turbostarter/shared/utils";
import { env } from "./env";
import { getUrl } from "./lib/utils";
import { AuthProvider, SocialProvider, VerificationType } from "./types";
/**
* Credits for new free-tier users.
* Configurable via FREE_TIER_CREDITS env var. Defaults: 10000 (dev), 100 (prod).
*/
const FREE_TIER_CREDITS =
env.FREE_TIER_CREDITS ??
(env.NODE_ENV === NodeEnv.DEVELOPMENT ? 10000 : 100);
export const auth = betterAuth({
appName: "TurboStarter",
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
user: {
deleteUser: {
enabled: true,
sendDeleteAccountVerification: async ({ user, url }, request) =>
sendEmail({
to: user.email,
template: EmailTemplate.DELETE_ACCOUNT,
locale: getLocaleFromRequest(request),
variables: {
url: getUrl({
request,
url,
type: VerificationType.DELETE_ACCOUNT,
}).toString(),
},
}),
},
changeEmail: {
enabled: true,
updateEmailWithoutVerification: true,
sendChangeEmailConfirmation: async ({ user, newEmail, url }, request) =>
sendEmail({
to: user.email,
template: EmailTemplate.CHANGE_EMAIL,
locale: getLocaleFromRequest(request),
variables: {
url: getUrl({
request,
url,
type: VerificationType.CONFIRM_EMAIL,
}).toString(),
newEmail,
},
}),
},
},
trustedOrigins: [
"chrome-extension://",
"turbostarter://",
/* Needed only for Apple ID authentication */
"https://appleid.apple.com",
...(env.NODE_ENV === NodeEnv.DEVELOPMENT
? ["http://localhost*", "https://localhost*"]
: []),
...(process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
],
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }, request) =>
sendEmail({
to: user.email,
template: EmailTemplate.RESET_PASSWORD,
locale: getLocaleFromRequest(request),
variables: {
url,
},
}),
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }, request) =>
sendEmail({
to: user.email,
template: EmailTemplate.CONFIRM_EMAIL,
locale: getLocaleFromRequest(request),
variables: {
url: getUrl({
request,
url,
type: VerificationType.CONFIRM_EMAIL,
}).toString(),
},
}),
},
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
databaseHooks: {
user: {
create: {
after: async (user) => {
// Auto-create customer record with free credits on signup
const customerId = generateId();
try {
await db.transaction(async (tx) => {
await tx.insert(customer).values({
id: customerId,
userId: user.id,
customerId: `free_${user.id}`,
status: "active",
plan: "free",
credits: FREE_TIER_CREDITS,
});
await tx.insert(creditTransaction).values({
id: generateId(),
customerId,
amount: FREE_TIER_CREDITS,
type: "signup",
reason: "Welcome credits for new user",
balanceAfter: FREE_TIER_CREDITS,
});
});
logger.info(`Created customer with ${FREE_TIER_CREDITS} credits for user ${user.id}`);
} catch (error) {
// Log but don't fail user creation if customer creation fails
logger.error("Failed to create customer for user", { userId: user.id, error });
}
},
},
},
},
plugins: [
magicLink({
sendMagicLink: async ({ email, url }, ctx) =>
sendEmail({
to: email,
template: EmailTemplate.MAGIC_LINK,
locale: getLocaleFromRequest(ctx?.request),
variables: {
url: getUrl({
request: ctx?.request,
url,
type: VerificationType.MAGIC_LINK,
}).toString(),
},
}),
}),
passkey(),
twoFactor(),
anonymous(),
admin(),
organization({
sendInvitationEmail: async (
{ invitation, inviter, organization },
request,
) => {
const url = getUrl({
request,
});
url.searchParams.set("invitationId", invitation.id);
url.searchParams.set("email", invitation.email);
return sendEmail({
to: invitation.email,
template: EmailTemplate.ORGANIZATION_INVITATION,
locale: getLocaleFromRequest(request),
variables: {
url: url.toString(),
inviter: inviter.user.name,
organization: organization.name,
},
});
},
}),
lastLoginMethod({
customResolveMethod: (ctx) => {
switch (ctx.path) {
case "/magic-link/verify":
return AuthProvider.MAGIC_LINK;
case "/passkey/verify-authentication":
return AuthProvider.PASSKEY;
default:
return null;
}
},
}),
expo(),
nextCookies(),
],
socialProviders: {
[SocialProvider.APPLE]: {
clientId: env.APPLE_CLIENT_ID,
clientSecret: env.APPLE_CLIENT_SECRET,
appBundleIdentifier: env.APPLE_APP_BUNDLE_IDENTIFIER,
},
[SocialProvider.GOOGLE]: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
[SocialProvider.GITHUB]: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
},
advanced: {
cookiePrefix: "turbostarter",
cookies: {
state: {
attributes: {
sameSite: "none",
secure: true,
},
},
},
},
logger: {
log: (level, ...args) => logger[level](...args),
},
});
export type AuthErrorCode = keyof typeof auth.$ERROR_CODES;
export type Session = typeof auth.$Infer.Session;
export type User = Session["user"];
export type Invitation = typeof auth.$Infer.Invitation;
export type Organization = typeof auth.$Infer.Organization;
export type ActiveOrganization = typeof auth.$Infer.ActiveOrganization;
export type Member = typeof auth.$Infer.Member;