From 702156c406d47c1adf068e40c780f770070ae63b Mon Sep 17 00:00:00 2001 From: Hammer Date: Wed, 28 Jan 2026 21:46:02 +0000 Subject: [PATCH] fix: disable open signup - invite creates users directly via DB --- src/lib/auth.ts | 3 ++- src/routes/invite.ts | 60 +++++++++++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1324aad..d4a7788 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -28,7 +28,8 @@ export const auth = betterAuth({ }, emailAndPassword: { enabled: true, - requireEmailVerification: false, // Enable later for production + requireEmailVerification: false, + disableSignUp: true, // Registration is invite-only }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days diff --git a/src/routes/invite.ts b/src/routes/invite.ts index 3e31f74..3ef0f6e 100644 --- a/src/routes/invite.ts +++ b/src/routes/invite.ts @@ -1,8 +1,7 @@ import { Elysia, t } from 'elysia'; import { db } from '../db'; -import { invites, users } from '../db/schema'; +import { invites, users, accounts } from '../db/schema'; import { eq, and } from 'drizzle-orm'; -import { auth } from '../lib/auth'; export const inviteRoutes = new Elysia({ prefix: '/auth/invite' }) // Validate invite token (public - no auth required) @@ -18,7 +17,6 @@ export const inviteRoutes = new Elysia({ prefix: '/auth/invite' }) } if (new Date() > invite.expiresAt) { - // Mark as expired await db.update(invites) .set({ status: 'expired' }) .where(eq(invites.id, invite.id)); @@ -38,6 +36,7 @@ export const inviteRoutes = new Elysia({ prefix: '/auth/invite' }) }) // Accept invite (public - no auth required) + // Creates user directly in DB, bypassing Better Auth's blocked signup endpoint .post('/:token/accept', async ({ params, body, set }: { params: { token: string }; body: { password: string; name?: string }; @@ -61,22 +60,44 @@ export const inviteRoutes = new Elysia({ prefix: '/auth/invite' }) throw new Error('Invite has expired'); } - // Create user via Better Auth's sign-up + // Check if user with this email already exists + const [existing] = await db.select({ id: users.id }) + .from(users) + .where(eq(users.email, invite.email)) + .limit(1); + + if (existing) { + set.status = 409; + throw new Error('An account with this email already exists'); + } + try { - const signUpResult = await auth.api.signUpEmail({ - body: { - email: invite.email, - password: body.password, - name: body.name || invite.name, - }, + const now = new Date(); + const userId = crypto.randomUUID(); + const accountId = crypto.randomUUID(); + const hashedPassword = await Bun.password.hash(body.password, { algorithm: 'bcrypt', cost: 10 }); + + // Create user record + await db.insert(users).values({ + id: userId, + email: invite.email, + name: body.name || invite.name, + role: invite.role, + emailVerified: false, + createdAt: now, + updatedAt: now, }); - // Set the user's role from the invite - if (signUpResult?.user?.id) { - await db.update(users) - .set({ role: invite.role }) - .where(eq(users.id, signUpResult.user.id)); - } + // Create credential account record (how Better Auth stores email/password) + await db.insert(accounts).values({ + id: accountId, + userId, + accountId: userId, + providerId: 'credential', + password: hashedPassword, + createdAt: now, + updatedAt: now, + }); // Mark invite as accepted await db.update(invites) @@ -86,12 +107,11 @@ export const inviteRoutes = new Elysia({ prefix: '/auth/invite' }) return { success: true, user: { - id: signUpResult.user.id, - email: signUpResult.user.email, - name: signUpResult.user.name, + id: userId, + email: invite.email, + name: body.name || invite.name, role: invite.role, }, - token: signUpResult.token, }; } catch (error: any) { set.status = 400;