feat: whyrating - initial project from turbostarter boilerplate

This commit is contained in:
Alejandro Gutiérrez
2026-02-04 01:54:52 +01:00
commit 5cdc07cd39
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
import { expoClient } from "@better-auth/expo/client";
import { lastLoginMethodClient } from "@better-auth/expo/plugins";
import {
magicLinkClient,
twoFactorClient,
anonymousClient,
adminClient,
organizationClient,
inferAdditionalFields,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { AuthMobileClientOptions } from "..";
import type { auth } from "../server";
import type { AuthClientOptions } from "../types";
import type { LastLoginMethodClientConfig } from "@better-auth/expo/plugins";
export const createClient = ({
mobile,
lastLoginMethod,
...options
}: AuthClientOptions & {
mobile: AuthMobileClientOptions;
lastLoginMethod: LastLoginMethodClientConfig;
}) =>
createAuthClient({
...options,
plugins: [
...(options.plugins ?? []),
anonymousClient(),
magicLinkClient(),
twoFactorClient(),
adminClient(),
organizationClient(),
lastLoginMethodClient(lastLoginMethod),
inferAdditionalFields<typeof auth>(),
expoClient(mobile),
],
});

View File

@@ -0,0 +1,30 @@
import { passkeyClient } from "@better-auth/passkey/client";
import {
magicLinkClient,
twoFactorClient,
anonymousClient,
adminClient,
organizationClient,
inferAdditionalFields,
lastLoginMethodClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { auth } from "../server";
import type { AuthClientOptions } from "../types";
export const createClient = (options?: AuthClientOptions) =>
createAuthClient({
...options,
plugins: [
...(options?.plugins ?? []),
passkeyClient(),
anonymousClient(),
magicLinkClient(),
twoFactorClient(),
adminClient(),
organizationClient(),
lastLoginMethodClient(),
inferAdditionalFields<typeof auth>(),
],
});

34
packages/auth/src/env.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineEnv } from "envin";
import * as z from "zod";
import { envConfig, NodeEnv } from "@turbostarter/shared/constants";
import type { Preset } from "envin/types";
export const preset = {
id: "auth",
server: {
BETTER_AUTH_SECRET: z.string().optional(),
APPLE_CLIENT_ID: z.string().optional().default(""),
APPLE_CLIENT_SECRET: z.string().optional().default(""),
APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional().default(""),
GOOGLE_CLIENT_ID: z.string().optional().default(""),
GOOGLE_CLIENT_SECRET: z.string().optional().default(""),
GITHUB_CLIENT_ID: z.string().optional().default(""),
GITHUB_CLIENT_SECRET: z.string().optional().default(""),
FREE_TIER_CREDITS: z.coerce.number().optional().default(100),
SEED_EMAIL: z.email().optional().default("me@turbostarter.dev"),
SEED_PASSWORD: z.string().optional().default("Pa$$w0rd"),
},
} as const satisfies Preset;
export const env = defineEnv({
...envConfig,
...preset,
shared: {
NODE_ENV: z.enum(NodeEnv).default(NodeEnv.DEVELOPMENT),
},
});

View File

@@ -0,0 +1,3 @@
export * from "./types";
export * from "./lib/utils";
export * from "./lib/schema";

View File

@@ -0,0 +1,150 @@
import * as z from "zod";
import { MemberRole, UserRole } from "../types";
const emailSchema = z.object({
email: z.email().max(254),
});
const password = z.string().min(8);
const passwordSchema = z.object({
password,
});
const otpSchema = z.object({
code: z.string().min(6).max(6),
});
const backupCodeSchema = z.object({
code: z.string().min(11).max(11),
});
const trustDeviceSchema = z.object({
trustDevice: z.boolean().optional(),
});
const updateUserSchema = z.object({
name: z.string().min(2).max(32).optional(),
role: z.enum(UserRole).optional(),
image: z.url().optional(),
});
const changePasswordSchema = z.object({
...passwordSchema.shape,
newPassword: password,
});
const registerSchema = z.object({
...emailSchema.shape,
...passwordSchema.shape,
});
const passwordLoginSchema = z.object({
...emailSchema.shape,
...passwordSchema.shape,
rememberMe: z.boolean().optional().default(true),
});
const magicLinkLoginSchema = emailSchema;
const forgotPasswordSchema = emailSchema;
const updatePasswordSchema = passwordSchema;
const otpVerificationSchema = z.object({
...otpSchema.shape,
...trustDeviceSchema.shape,
});
const backupCodeVerificationSchema = z.object({
...backupCodeSchema.shape,
...trustDeviceSchema.shape,
});
const createOrganizationSchema = z.object({
name: z.string().min(2).max(32),
});
const updateOrganizationSchema = z.object({
name: z.string().min(2).max(32).optional(),
slug: z.string().optional(),
logo: z.string().optional(),
});
const inviteMemberSchema = z.object({
email: z.email().max(254),
role: z.enum(MemberRole),
});
const updateMemberSchema = z.object({
role: z.enum(MemberRole).optional(),
});
const banUserSchema = z.object({
reason: z.string().min(1).max(1000).optional(),
expiresIn: z
.date()
.optional()
.refine((date) => !date || date > new Date()),
});
type EmailPayload = z.infer<typeof emailSchema>;
type PasswordLoginPayload = z.infer<typeof passwordLoginSchema>;
type MagicLinkLoginPayload = z.infer<typeof magicLinkLoginSchema>;
type RegisterPayload = z.infer<typeof registerSchema>;
type ForgotPasswordPayload = z.infer<typeof forgotPasswordSchema>;
type UpdatePasswordPayload = z.infer<typeof updatePasswordSchema>;
type PasswordPayload = z.infer<typeof passwordSchema>;
type UpdateUserPayload = z.infer<typeof updateUserSchema>;
type ChangePasswordPayload = z.infer<typeof changePasswordSchema>;
type OtpPayload = z.infer<typeof otpSchema>;
type OtpVerificationPayload = z.infer<typeof otpVerificationSchema>;
type BackupCodePayload = z.infer<typeof backupCodeSchema>;
type BackupCodeVerificationPayload = z.infer<
typeof backupCodeVerificationSchema
>;
type CreateOrganizationPayload = z.infer<typeof createOrganizationSchema>;
type UpdateOrganizationPayload = z.infer<typeof updateOrganizationSchema>;
type InviteMemberPayload = z.infer<typeof inviteMemberSchema>;
type UpdateMemberPayload = z.infer<typeof updateMemberSchema>;
type BanUserPayload = z.infer<typeof banUserSchema>;
export {
passwordSchema,
registerSchema,
passwordLoginSchema,
magicLinkLoginSchema,
forgotPasswordSchema,
updatePasswordSchema,
updateUserSchema,
emailSchema,
changePasswordSchema,
otpSchema,
otpVerificationSchema,
backupCodeSchema,
backupCodeVerificationSchema,
createOrganizationSchema,
updateOrganizationSchema,
inviteMemberSchema,
updateMemberSchema,
banUserSchema,
};
export type {
PasswordLoginPayload,
MagicLinkLoginPayload,
RegisterPayload,
ForgotPasswordPayload,
UpdatePasswordPayload,
PasswordPayload,
UpdateUserPayload,
EmailPayload,
ChangePasswordPayload,
OtpPayload,
OtpVerificationPayload,
BackupCodePayload,
BackupCodeVerificationPayload,
CreateOrganizationPayload,
UpdateOrganizationPayload,
InviteMemberPayload,
UpdateMemberPayload,
BanUserPayload,
};

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { MemberRole, UserRole } from "../../types";
import {
generateName,
getAllRolesAtOrAbove,
getAllRolesAtOrBelow,
getUrl,
hasAdminPermission,
} from "../utils";
import type { User } from "../../types";
const BASE_URL = "http://localhost:3000";
describe("getUrl", () => {
it("should use x-url header if present", () => {
const request = new Request(BASE_URL, {
headers: { "x-url": "https://custom-url.com/path" },
});
const url = getUrl({ request });
expect(url.toString()).toBe("https://custom-url.com/path");
});
it("should merge params from passed url into x-url", () => {
const request = new Request(BASE_URL, {
headers: { "x-url": "https://custom.com?foo=bar" },
});
// mergeSearchParams with overwrite: false (default behavior for x-url branch)
const url = getUrl({
request,
url: "https://ignored.com?baz=qux&foo=new",
});
// Should preserve foo=bar from x-url, add baz=qux
expect(url.searchParams.get("foo")).toBe("bar");
expect(url.searchParams.get("baz")).toBe("qux");
expect(url.origin).toBe("https://custom.com");
});
it("should use expo-origin header if present (mobile)", () => {
const request = new Request(BASE_URL, {
headers: { "expo-origin": "exp://192.168.1.1:8081" },
});
const url = getUrl({ request });
expect(url.toString()).toBe("exp://192.168.1.1:8081");
});
it("should replace params from passed url into expo-origin", () => {
const request = new Request(BASE_URL, {
headers: { "expo-origin": "exp://host" },
});
const url = getUrl({
request,
url: "https://ignored.com?foo=bar",
});
// Mobile branch uses mergeSearchParams with replace: true
expect(url.searchParams.get("foo")).toBe("bar");
expect(url.protocol).toBe("exp:");
expect(url.host).toBe("host");
});
it("should fallback to request url for web", () => {
const request = new Request("https://web.com/current");
const url = getUrl({ request });
expect(url.toString()).toBe("https://web.com/current");
});
it("should use provided url for web if no headers", () => {
const request = new Request("https://web.com");
const url = getUrl({ request, url: "https://target.com/page" });
expect(url.toString()).toBe("https://target.com/page");
});
it("should append type param if provided", () => {
const request = new Request("https://web.com");
const url = getUrl({ request, type: "invite" });
expect(url.searchParams.get("type")).toBe("invite");
});
});
describe("generateName", () => {
it("should generate name from email", () => {
expect(generateName("john.doe@example.com")).toBe("john.doe");
});
it.each([[undefined, null, "john.doe@example.com"]])(
"should fallback to Anonymous if email is empty",
(email) => {
expect(generateName(email)).toBe("Anonymous");
},
);
});
describe("getAllRolesAtOrBelow", () => {
it.each([
[MemberRole.OWNER, [MemberRole.MEMBER, MemberRole.ADMIN, MemberRole.OWNER]],
[MemberRole.MEMBER, [MemberRole.MEMBER]],
])("should return correct roles for %s", (inputRole, expectedRoles) => {
const roles = getAllRolesAtOrBelow(inputRole);
expect(roles).toEqual(expectedRoles);
});
});
describe("getAllRolesAtOrAbove", () => {
it.each([
[MemberRole.OWNER, [MemberRole.OWNER]],
[
MemberRole.MEMBER,
[MemberRole.MEMBER, MemberRole.ADMIN, MemberRole.OWNER],
],
])("should return correct roles for %s", (inputRole, expectedRoles) => {
const roles = getAllRolesAtOrAbove(inputRole);
expect(roles).toEqual(expectedRoles);
});
});
describe("hasAdminPermission", () => {
it("should return true if user has ADMIN role", () => {
const user = { role: UserRole.ADMIN } as User;
expect(hasAdminPermission(user)).toBe(true);
});
it("should return true if user has multiple roles including ADMIN", () => {
const user = { role: `${UserRole.USER},${UserRole.ADMIN}` } as User;
expect(hasAdminPermission(user)).toBe(true);
});
it("should return false if user does not have ADMIN role", () => {
const user = { role: UserRole.USER } as User;
expect(hasAdminPermission(user)).toBe(false);
});
it("should return false if user is missing role", () => {
const user = {} as User;
expect(hasAdminPermission(user)).toBe(false);
});
});

View File

@@ -0,0 +1,76 @@
import { getOrigin, mergeSearchParams } from "@turbostarter/shared/utils";
import { MemberRole, UserRole } from "../types";
import type { User } from "../types";
export const getUrl = ({
request,
url,
type,
}: {
request?: Request;
url?: string;
type?: string;
}) => {
const passedUrl = request?.headers.get("x-url");
const expoOrigin = request?.headers.get("expo-origin");
let resultUrl: URL;
if (passedUrl) {
// Base on x-url; merge in params from provided `url` without overwriting existing keys
resultUrl = new URL(passedUrl);
if (url) {
const urlObj = new URL(
url,
getOrigin(resultUrl.toString()) ?? expoOrigin ?? undefined,
);
mergeSearchParams(resultUrl, urlObj, { overwrite: false });
}
} else if (expoOrigin) {
// For Expo/mobile, base on expo-origin; if `url` has a query, adopt it entirely
resultUrl = new URL(expoOrigin);
if (url) {
const targetUrl = new URL(url);
if (targetUrl.search) {
mergeSearchParams(resultUrl, targetUrl, { replace: true });
}
}
} else {
// For web, use the provided URL or fall back to the request URL
resultUrl = new URL(url ?? request?.url ?? "");
}
if (type) {
resultUrl.searchParams.set("type", type);
}
return resultUrl;
};
const hierarchy: MemberRole[] = [
MemberRole.MEMBER,
MemberRole.ADMIN,
MemberRole.OWNER,
];
export const generateName = (email?: string) => {
return email?.split("@")[0] ?? "Anonymous";
};
export const getAllRolesAtOrBelow = (role: MemberRole): MemberRole[] => {
const idx = hierarchy.indexOf(role);
if (idx === -1) return [];
return hierarchy.slice(0, idx + 1);
};
export const getAllRolesAtOrAbove = (role: MemberRole): MemberRole[] => {
const idx = hierarchy.indexOf(role);
if (idx === -1) return [];
return hierarchy.slice(idx, hierarchy.length);
};
export const hasAdminPermission = (user: User) =>
user.role?.split(",").includes(UserRole.ADMIN) ?? false;

View File

@@ -0,0 +1,242 @@
import { eq } from "@turbostarter/db";
import * as schema from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import { logger } from "@turbostarter/shared/logger";
import { env } from "../env";
import { generateName } from "../lib/utils";
import { auth } from "../server";
import { MemberRole, UserRole } from "../types";
const context = await auth.$context;
const SEED_PASSWORD_HASH = await context.password.hash(env.SEED_PASSWORD);
const getEmail = (suffix: string | string[]) => {
const [name, domain] = env.SEED_EMAIL.split("@");
return `${name}+${Array.isArray(suffix) ? suffix.join("-") : suffix}@${domain}`;
};
const getImage = (name: string) => `https://avatar.vercel.sh/${name}`;
const seedUser = async ({
email,
role = UserRole.USER,
}: {
email: string;
role?: UserRole;
}) => {
const name = generateName(email);
return await db.transaction(async (tx) => {
const userId = context.generateId({ model: "user" }).toString();
const userToInsert = {
name,
email,
role,
image: getImage(name),
emailVerified: true,
isAnonymous: false,
};
const [user] = await tx
.insert(schema.user)
.values({ ...userToInsert, id: userId })
.onConflictDoUpdate({
target: schema.user.email,
set: userToInsert,
})
.returning();
if (!user) {
return;
}
const alreadyExistingAccount = await tx.query.account.findFirst({
where: (account, { eq, and }) =>
and(eq(account.userId, user.id), eq(account.providerId, "credential")),
});
if (!alreadyExistingAccount) {
const accountToInsert = {
id: context.generateId({ model: "account" }).toString(),
accountId: context.generateId({ model: "account" }).toString(),
providerId: "credential",
password: SEED_PASSWORD_HASH,
userId: user.id,
};
await tx.insert(schema.account).values(accountToInsert);
}
return user;
});
};
const seedOrganizationMember = async ({
organizationId,
role,
}: {
organizationId: string;
role: MemberRole;
}) =>
db.transaction(async (tx) => {
const email = getEmail([`org`, role]);
const user = await seedUser({ email });
if (!user) {
return;
}
const memberToInsert = {
id: context.generateId({ model: "member" }).toString(),
organizationId,
role,
userId: user.id,
createdAt: new Date(),
};
const alreadyExistingMember = await tx.query.member.findFirst({
where: (member, { eq, and }) =>
and(
eq(member.userId, memberToInsert.userId),
eq(member.organizationId, memberToInsert.organizationId),
),
});
if (alreadyExistingMember) {
const [updatedMember] = await tx
.update(schema.member)
.set(memberToInsert)
.where(eq(schema.member.id, alreadyExistingMember.id))
.returning();
return updatedMember;
}
const [member] = await tx
.insert(schema.member)
.values(memberToInsert)
.returning();
return member;
});
const seedOrganizationInvitation = async ({
organizationId,
inviterId,
role,
}: {
organizationId: string;
inviterId: string;
role: MemberRole;
}) =>
db.transaction(async (tx) => {
const invitationToInsert = {
id: context.generateId({ model: "invitation" }).toString(),
organizationId,
role,
email: getEmail([`org`, `invite`, role]),
expiresAt: new Date(
Date.now() +
(Math.random() < 0.5 ? -1 : 1) *
Math.floor(Math.random() * 1000 * 60 * 60 * 24),
),
inviterId,
};
const alreadyExistingInvitation = await tx.query.invitation.findFirst({
where: (invitation, { eq, and }) =>
and(
eq(invitation.organizationId, invitationToInsert.organizationId),
eq(invitation.email, invitationToInsert.email),
),
});
if (alreadyExistingInvitation) {
const [updatedInvitation] = await tx
.update(schema.invitation)
.set(invitationToInsert)
.where(eq(schema.invitation.id, alreadyExistingInvitation.id))
.returning();
return updatedInvitation;
}
const [invitation] = await tx
.insert(schema.invitation)
.values(invitationToInsert)
.returning();
return invitation;
});
const seedOrganization = async () => {
const organizationId = context
.generateId({ model: "organization" })
.toString();
const organizationSlug = "seed-organization";
const organizationToInsert = {
name: organizationSlug,
slug: organizationSlug,
logo: getImage(organizationSlug),
createdAt: new Date(),
};
const [organization] = await db
.insert(schema.organization)
.values({ ...organizationToInsert, id: organizationId })
.onConflictDoUpdate({
target: schema.organization.slug,
set: organizationToInsert,
})
.returning();
if (!organization) {
return;
}
const members = await Promise.all(
Object.values(MemberRole).map((role) =>
seedOrganizationMember({ organizationId: organization.id, role }),
),
);
await Promise.all(
members.flatMap((member) =>
Object.values(MemberRole)
.filter((role) => role !== MemberRole.OWNER)
.map((role) =>
seedOrganizationInvitation({
organizationId: organization.id,
role,
inviterId: member?.userId ?? "",
}),
),
),
);
return organization;
};
const seedUsers = async () =>
Promise.all(
Object.values(UserRole).map((role) =>
seedUser({ email: getEmail(role), role }),
),
);
async function main() {
await seedUsers();
await seedOrganization();
logger.info("Auth seeded successfully");
process.exit(0);
}
main().catch((error) => {
logger.error(error);
process.exit(1);
});

255
packages/auth/src/server.ts Normal file
View File

@@ -0,0 +1,255 @@
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;

271
packages/auth/src/types.ts Normal file
View File

@@ -0,0 +1,271 @@
import * as z from "zod";
import type { AuthErrorCode } from "./server";
import type { expoClient } from "@better-auth/expo/client";
import type { TranslationKey } from "@turbostarter/i18n";
import type {
BetterAuthClientPlugin,
BetterAuthClientOptions,
} from "better-auth";
type AuthMobileClientOptions = Parameters<typeof expoClient>[0];
const SocialProvider = {
APPLE: "apple",
GOOGLE: "google",
GITHUB: "github",
} as const;
type SocialProvider = (typeof SocialProvider)[keyof typeof SocialProvider];
const AuthProvider = {
...SocialProvider,
PASSWORD: "password",
MAGIC_LINK: "magicLink",
ANONYMOUS: "anonymous",
PASSKEY: "passkey",
} as const;
type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider];
const SecondFactor = {
TOTP: "totp",
BACKUP_CODE: "backupCode",
} as const;
type SecondFactor = (typeof SecondFactor)[keyof typeof SecondFactor];
const authConfigSchema = z.object({
providers: z.object({
[AuthProvider.PASSWORD]: z.boolean(),
[AuthProvider.MAGIC_LINK]: z.boolean(),
[AuthProvider.ANONYMOUS]: z.boolean(),
[AuthProvider.PASSKEY]: z.boolean().optional(),
oAuth: z.array(z.enum(SocialProvider)),
}),
});
const UserRole = {
USER: "user",
ADMIN: "admin",
} as const;
type UserRole = (typeof UserRole)[keyof typeof UserRole];
const MemberRole = {
MEMBER: "member",
ADMIN: "admin",
OWNER: "owner",
} as const;
type MemberRole = (typeof MemberRole)[keyof typeof MemberRole];
const InvitationStatus = {
PENDING: "pending",
ACCEPTED: "accepted",
CANCELED: "canceled",
REJECTED: "rejected",
} as const;
type InvitationStatus =
(typeof InvitationStatus)[keyof typeof InvitationStatus];
const VerificationType = {
MAGIC_LINK: "magic-link",
DELETE_ACCOUNT: "delete-account",
CONFIRM_EMAIL: "confirm-email",
} as const;
type VerificationType =
(typeof VerificationType)[keyof typeof VerificationType];
type AuthConfig = z.infer<typeof authConfigSchema>;
const ERROR_MESSAGES: Record<AuthErrorCode, TranslationKey> = {
YOU_CANNOT_BAN_YOURSELF: "auth:error.user.cannotBanYourself",
YOU_CANNOT_IMPERSONATE_ADMINS: "auth:error.user.cannotImpersonateAdmins",
INVALID_EMAIL_FORMAT: "auth:error.credentials.email.invalidFormat",
ORGANIZATION_SLUG_ALREADY_TAKEN: "organization:error.slugAlreadyTaken",
YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE:
"auth:error.cannotSetNonExistentValue",
YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE: "auth:error.user.cannotChangeRole",
YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS: "admin:error.cannotCreateUsers",
YOU_ARE_NOT_ALLOWED_TO_LIST_USERS: "admin:error.cannotListUsers",
YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS: "admin:error.cannotUpdateUsers",
YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS: "admin:error.cannotDeleteUsers",
YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS:
"admin:error.cannotListUsersSessions",
YOU_ARE_NOT_ALLOWED_TO_BAN_USERS: "admin:error.cannotBanUsers",
YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS:
"admin:error.cannotImpersonateUsers",
YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS:
"admin:error.cannotRevokeUsersSessions",
YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD:
"admin:error.cannotSetUsersPassword",
BANNED_USER: "auth:error.user.banned",
NO_DATA_TO_UPDATE: "auth:error.noDataToUpdate",
YOU_CANNOT_REMOVE_YOURSELF: "auth:error.user.cannotRemoveYourself",
YOU_ARE_NOT_ALLOWED_TO_GET_USER: "auth:error.user.cannotGetUser",
YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER:
"organization:error.cannotLeaveWithoutOwner",
MISSING_AC_INSTANCE: "organization:error.ac.missingAcInstance",
YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE:
"organization:error.ac.mustBeInOrganizationToCreateRole",
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE:
"organization:error.ac.cannotCreateRole",
YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE:
"organization:error.ac.cannotUpdateRole",
YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE:
"organization:error.ac.cannotDeleteRole",
YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE: "organization:error.ac.cannotReadRole",
YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE: "organization:error.ac.cannotListRole",
YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE: "organization:error.ac.cannotGetRole",
TOO_MANY_ROLES: "organization:error.ac.tooManyRoles",
INVALID_RESOURCE: "organization:error.ac.invalidResource",
CANNOT_DELETE_A_PRE_DEFINED_ROLE:
"organization:error.ac.cannotDeletePreDefinedRole",
ROLE_NAME_IS_ALREADY_TAKEN: "organization:error.ac.roleNameAlreadyTaken",
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION:
"organization:error.cannotCreateNew",
YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS:
"organization:error.maximumNumberOfOrganizations",
ORGANIZATION_ALREADY_EXISTS: "organization:error.alreadyExists",
ORGANIZATION_NOT_FOUND: "organization:error.notFound",
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
"auth:error.user.alreadyExistsUseAnotherEmail",
USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION: "organization:error.userNotMember",
YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION:
"organization:error.cannotUpdate",
YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION:
"organization:error.cannotDelete",
NO_ACTIVE_ORGANIZATION: "organization:error.noActive",
USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION:
"organization:error.userAlreadyMember",
MEMBER_NOT_FOUND: "organization:error.memberNotFound",
YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM: "organization:error.team.noActive",
YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION: "organization:error.userNotMember",
ROLE_NOT_FOUND: "organization:error.roleNotFound",
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER:
"organization:error.team.cannotCreateNewMember",
YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER:
"organization:error.team.cannotRemoveMember",
YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION:
"organization:error.cannotAccess",
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM:
"organization:error.team.cannotCreateNew",
TEAM_ALREADY_EXISTS: "organization:error.team.alreadyExists",
TEAM_NOT_FOUND: "organization:error.team.notFound",
YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER:
"organization:error.cannotLeaveAsOnlyOwner",
YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER:
"organization:error.cannotDeleteMember",
YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION:
"organization:error.invitation.cannotInviteUsers",
YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION:
"organization:error.invitation.cannotCancel",
INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION:
"organization:error.invitation.inviterNoLongerMember",
YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE:
"organization:error.invitation.cannotInviteUserWithRole",
USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION:
"organization:error.invitation.userAlreadyInvited",
INVITATION_NOT_FOUND: "organization:error.invitation.notFound",
YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION:
"organization:error.team.cannotCreateNew",
YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION:
"organization:error.team.cannotDelete",
YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM:
"organization:error.team.cannotUpdate",
YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM:
"organization:error.team.cannotDelete",
INVITATION_LIMIT_REACHED: "organization:error.invitation.limitReached",
TEAM_MEMBER_LIMIT_REACHED: "organization:error.team.memberLimitReached",
USER_IS_NOT_A_MEMBER_OF_THE_TEAM: "organization:error.team.userNotMember",
YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM:
"organization:error.team.cannotAccessMembers",
YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS:
"organization:error.team.maximumNumberOfTeams",
UNABLE_TO_REMOVE_LAST_TEAM: "organization:error.team.unableToRemoveLastTeam",
EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION:
"organization:error.invitation.emailVerificationRequired",
FAILED_TO_RETRIEVE_INVITATION:
"organization:error.invitation.failedToRetrieve",
YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER:
"organization:error.cannotUpdateMember",
ORGANIZATION_MEMBERSHIP_LIMIT_REACHED:
"organization:error.membershipLimitReached",
YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION:
"organization:error.invitation.notRecipient",
USER_NOT_FOUND: "auth:error.user.notFound",
USER_ALREADY_HAS_PASSWORD: "auth:error.user.alreadyHasPassword",
AUTHENTICATION_FAILED: "auth:error.authenticationFailed",
FAILED_TO_CREATE_USER: "auth:error.account.creation",
FAILED_TO_CREATE_SESSION: "auth:error.session.creation",
UNABLE_TO_CREATE_SESSION: "auth:error.session.creation",
COULD_NOT_CREATE_SESSION: "auth:error.session.creation",
FAILED_TO_UPDATE_USER: "auth:error.account.update",
FAILED_TO_GET_SESSION: "auth:error.session.retrieval",
INVALID_PASSWORD: "auth:error.credentials.password.invalid",
INVALID_EMAIL: "auth:error.credentials.email.invalid",
INVALID_EMAIL_OR_PASSWORD: "auth:error.credentials.invalidEmailOrPassword",
SOCIAL_ACCOUNT_ALREADY_LINKED: "auth:error.social.alreadyLinked",
PROVIDER_NOT_FOUND: "auth:error.social.providerNotFound",
INVALID_TOKEN: "auth:error.token.invalid",
ID_TOKEN_NOT_SUPPORTED: "auth:error.token.idNotSupported",
FAILED_TO_GET_USER_INFO: "auth:error.user.infoNotFound",
USER_EMAIL_NOT_FOUND: "auth:error.user.emailNotFound",
EMAIL_NOT_VERIFIED: "auth:error.credentials.email.notVerified",
PASSWORD_TOO_SHORT: "auth:error.credentials.password.tooShort",
PASSWORD_TOO_LONG: "auth:error.credentials.password.tooLong",
USER_ALREADY_EXISTS: "auth:error.user.alreadyExists",
EMAIL_CAN_NOT_BE_UPDATED: "auth:error.credentials.email.cannotUpdate",
CREDENTIAL_ACCOUNT_NOT_FOUND: "auth:error.credentials.notFound",
SESSION_EXPIRED: "auth:error.session.expired",
FAILED_TO_UNLINK_LAST_ACCOUNT: "auth:error.social.unlinkLastAccount",
ACCOUNT_NOT_FOUND: "auth:error.user.accountNotFound",
CHALLENGE_NOT_FOUND: "auth:error.passkey.challengeNotFound",
YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY: "auth:error.passkey.notAllowed",
FAILED_TO_VERIFY_REGISTRATION: "auth:error.passkey.verificationFailed",
PASSKEY_NOT_FOUND: "auth:error.passkey.notFound",
FAILED_TO_UPDATE_PASSKEY: "auth:error.passkey.updateFailed",
ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY:
"auth:error.anonymous.cannotSignInAgain",
OTP_NOT_ENABLED: "auth:error.otp.notEnabled",
OTP_HAS_EXPIRED: "auth:error.otp.expired",
TOTP_NOT_ENABLED: "auth:error.totp.notEnabled",
TWO_FACTOR_NOT_ENABLED: "auth:error.twoFactor.notEnabled",
BACKUP_CODES_NOT_ENABLED: "auth:error.backupCodes.notEnabled",
INVALID_BACKUP_CODE: "auth:error.code.invalid",
INVALID_CODE: "auth:error.code.invalid",
TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: "auth:error.code.tooManyAttempts",
INVALID_TWO_FACTOR_COOKIE: "auth:error.twoFactor.invalidCookie",
} as const;
export type {
BetterAuthClientOptions as AuthClientOptions,
BetterAuthClientPlugin as AuthClientPlugin,
AuthMobileClientOptions,
AuthConfig,
AuthErrorCode,
};
export {
authConfigSchema,
SocialProvider,
AuthProvider,
SecondFactor,
ERROR_MESSAGES,
MemberRole,
UserRole,
InvitationStatus,
VerificationType,
};
export type {
User,
Session,
Invitation,
Organization,
ActiveOrganization,
Member,
} from "./server";