diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 827b242..fdce523 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,10 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - run: bun install --frozen-lockfile @@ -45,7 +49,7 @@ jobs: with: directory: ./packages flags: unittests - fail_ci_if_error: true + fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/packages/firestore/package.json b/packages/firestore/package.json index 0a6e219..7957ed5 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -38,7 +38,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest", "check-types": "tsc --noEmit", "lint": "eslint .", diff --git a/packages/waitlist/package.json b/packages/waitlist/package.json index 7f20d15..af50b06 100644 --- a/packages/waitlist/package.json +++ b/packages/waitlist/package.json @@ -1,27 +1,28 @@ { "name": "@please-auth/waitlist", - "version": "0.1.0", "type": "module", + "version": "0.1.0", "description": "Waitlist / early-access plugin for Better Auth. Intercepts all registration paths and gates sign-ups behind an invite-based waitlist.", "author": "Minsu Lee", "license": "MIT", - "main": "dist/index.mjs", - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "publishConfig": { - "access": "public" + "homepage": "https://github.com/chatbot-pf/please-auth#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/chatbot-pf/please-auth.git", + "directory": "packages/waitlist" }, - "scripts": { - "test": "vitest", - "coverage": "vitest run --coverage --coverage.provider=istanbul", - "lint": "eslint .", - "lint:fix": "eslint --fix .", - "lint:package": "publint run --strict", - "lint:types": "attw --profile esm-only --pack .", - "build": "tsdown", - "dev": "tsdown --watch", - "typecheck": "tsc --noEmit" + "bugs": { + "url": "https://github.com/chatbot-pf/please-auth/issues" }, + "keywords": [ + "better-auth", + "auth", + "waitlist", + "early-access", + "invite", + "plugin", + "typescript" + ], "exports": { ".": { "dev-source": "./src/index.ts", @@ -34,6 +35,12 @@ "default": "./dist/client.mjs" } }, + "main": "dist/index.mjs", + "module": "dist/index.mjs", + "types": "dist/index.d.mts", + "publishConfig": { + "access": "public" + }, "typesVersions": { "*": { "*": ["./dist/index.d.mts"], @@ -41,26 +48,16 @@ } }, "files": ["dist"], - "keywords": [ - "better-auth", - "auth", - "waitlist", - "early-access", - "invite", - "plugin", - "typescript" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/chatbot-pf/please-auth.git", - "directory": "packages/waitlist" - }, - "homepage": "https://github.com/chatbot-pf/please-auth#readme", - "bugs": { - "url": "https://github.com/chatbot-pf/please-auth/issues" - }, - "dependencies": { - "zod": "^4.3.5" + "scripts": { + "test": "vitest", + "coverage": "vitest run --coverage --coverage.provider=istanbul", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "lint:package": "publint run --strict", + "lint:types": "attw --profile esm-only --pack .", + "build": "tsdown", + "dev": "tsdown --watch", + "typecheck": "tsc --noEmit" }, "peerDependencies": { "@better-auth/core": "^1.0.0", @@ -73,6 +70,9 @@ "optional": true } }, + "dependencies": { + "zod": "^4.3.5" + }, "devDependencies": { "@better-auth/core": "1.5.4", "@better-fetch/fetch": "1.1.21", diff --git a/packages/waitlist/src/__tests__/waitlist.test.ts b/packages/waitlist/src/__tests__/waitlist.test.ts index 55a4f6c..0156edd 100644 --- a/packages/waitlist/src/__tests__/waitlist.test.ts +++ b/packages/waitlist/src/__tests__/waitlist.test.ts @@ -1,1275 +1,1276 @@ -import { createAuthClient } from "better-auth/client"; -import { admin } from "better-auth/plugins/admin"; -import { anonymous } from "better-auth/plugins/anonymous"; -import { getTestInstance } from "better-auth/test"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { waitlistClient } from "../client"; -import { waitlist } from "../index"; -import type { WaitlistEntry } from "../types"; - -describe("waitlist plugin", async () => { - const joinedEntries: WaitlistEntry[] = []; - const approvedEntries: WaitlistEntry[] = []; - const rejectedEntries: WaitlistEntry[] = []; - const sentEmails: Array<{ - email: string; - inviteCode: string; - expiresAt: Date; - }> = []; - - const { client, auth, signInWithUser, customFetchImpl } = - await getTestInstance( - { - plugins: [ - waitlist({ - onJoinWaitlist: async (entry) => { - joinedEntries.push(entry); - }, - onApproved: async (entry) => { - approvedEntries.push(entry); - }, - onRejected: async (entry) => { - rejectedEntries.push(entry); - }, - sendInviteEmail: async (data) => { - sentEmails.push(data); - }, - adminRoles: ["admin"], - }), - admin({ - defaultRole: "user", - }), - ], - databaseHooks: { - user: { - create: { - before: async (user) => { - if (user.name === "Admin") { - return { - data: { - ...user, - role: "admin", - }, - }; - } - }, - }, - }, - }, - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - // Helper: create an admin user and sign in, returning session headers - async function createAdminAndSignIn() { - const email = "admin@test.com"; - const password = "admin123456"; - - // Sign up the admin user (name "Admin" triggers the databaseHook above) - // But the waitlist hook will block signup for non-approved emails - // So we first add the admin to the waitlist and approve them via the adapter - await auth.api.signUpEmail({ - body: { - email, - password, - name: "Admin", - }, - }); - - const { headers } = await signInWithUser(email, password); - return headers; - } - - // We need the admin to be created, but the waitlist blocks user creation - // for non-approved emails via databaseHooks. Let's pre-approve the admin - // email via the adapter directly before creating the admin user. - const ctx = await auth.$context; - await ctx.adapter.create({ - model: "waitlist", - data: { - email: "admin@test.com", - status: "approved", - lookupToken: crypto.randomUUID(), - inviteCode: null, - inviteExpiresAt: null, - referredBy: null, - metadata: null, - approvedAt: new Date(), - rejectedAt: null, - registeredAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - let adminHeaders: Headers; - - it("should setup admin user", async () => { - adminHeaders = await createAdminAndSignIn(); - expect(adminHeaders.get("cookie")).toBeDefined(); - }); - - // ========================================================================= - // JOIN WAITLIST - // ========================================================================= - - // Store lookup tokens for status checks - const lookupTokens: Record = {}; - - describe("join waitlist", () => { - it("should add email to waitlist with pending status", async () => { - const res = await client.waitlist.join({ - email: "user1@test.com", - }); - expect(res.data).toBeDefined(); - expect(res.data?.status).toBe("pending"); - expect(res.data?.email).toBe("user1@test.com"); - expect((res.data as Record)?.lookupToken).toBeDefined(); - lookupTokens["user1@test.com"] = (res.data as Record).lookupToken as string; - }); - - it("should reject duplicate email", async () => { - const res = await client.waitlist.join({ - email: "user1@test.com", - }); - expect(res.error).toBeDefined(); - expect(res.error?.status).toBe(400); - }); - - it("should normalize email to lowercase", async () => { - const res = await client.waitlist.join({ - email: "User2@Test.COM", - }); - expect(res.data).toBeDefined(); - expect(res.data?.email).toBe("user2@test.com"); - }); - - it("should include referredBy", async () => { - const res = await client.waitlist.join({ - email: "user3@test.com", - referredBy: "friend123", - }); - expect(res.data).toBeDefined(); - expect(res.data?.status).toBe("pending"); - }); - - it("should call onJoinWaitlist callback", () => { - expect(joinedEntries.length).toBeGreaterThan(0); - const found = joinedEntries.find((e) => e.email === "user1@test.com"); - expect(found).toBeDefined(); - }); - - it("should reject invalid email format", async () => { - const res = await client.waitlist.join({ - email: "not-an-email", - }); - expect(res.error).toBeDefined(); - }); - }); - - // ========================================================================= - // CHECK STATUS - // ========================================================================= - - describe("check status", () => { - it("should return correct status for existing token", async () => { - const res = await client.waitlist.status({ - query: { token: lookupTokens["user1@test.com"]! }, - }); - expect(res.data).toBeDefined(); - expect((res.data as Record)?.status).toBe("pending"); - }); - - it("should return 404 for unknown token", async () => { - const res = await client.waitlist.status({ - query: { token: "nonexistent-token" }, - }); - expect(res.error).toBeDefined(); - expect(res.error?.status).toBe(404); - }); - }); - - // ========================================================================= - // ADMIN: APPROVE - // ========================================================================= - - describe("admin approve", () => { - it("should approve an entry and generate invite code", async () => { - const approvedCountBefore = approvedEntries.length; - const emailCountBefore = sentEmails.length; - - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/approve", { - method: "POST", - body: { email: "user1@test.com" }, - }); - - expect(res.data).toBeDefined(); - expect((res.data as Record).status).toBe("approved"); - expect((res.data as Record).inviteCode).toBeDefined(); - - // Should have called onApproved callback - expect(approvedEntries.length).toBeGreaterThan(approvedCountBefore); - - // Should have called sendInviteEmail - expect(sentEmails.length).toBeGreaterThan(emailCountBefore); - const lastEmail = sentEmails[sentEmails.length - 1]; - expect(lastEmail).toBeDefined(); - expect(lastEmail?.email).toBe("user1@test.com"); - expect(lastEmail?.inviteCode).toBeDefined(); - expect(lastEmail?.expiresAt).toBeInstanceOf(Date); - }); - - it("should reject approve for already registered entry", async () => { - // First approve user2 and then simulate registration - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - await adminClient.$fetch("/waitlist/approve", { - method: "POST", - body: { email: "user2@test.com" }, - }); - - // Register user2 (signup should work since they're approved) - await auth.api.signUpEmail({ - body: { - email: "user2@test.com", - password: "password123", - name: "User Two", - }, - }); - - // Now trying to approve again should fail (status is "registered") - const res = await adminClient.$fetch("/waitlist/approve", { - method: "POST", - body: { email: "user2@test.com" }, - }); - expect(res.error).toBeDefined(); - }); - - it("should return 404 for non-existent entry", async () => { - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/approve", { - method: "POST", - body: { email: "nonexistent@test.com" }, - }); - expect(res.error).toBeDefined(); - expect(res.error?.status).toBe(404); - }); - - it("should deny non-admin users", async () => { - // Create a regular user (first approve on waitlist, then sign up) - await client.waitlist.join({ email: "regular@test.com" }); - - // Approve via adapter directly - await ctx.adapter.update({ - model: "waitlist", - where: [{ field: "email", value: "regular@test.com" }], - update: { - status: "approved", - approvedAt: new Date(), - updatedAt: new Date(), - }, - }); - - await auth.api.signUpEmail({ - body: { - email: "regular@test.com", - password: "password123", - name: "Regular User", - }, - }); - - const { headers: regularHeaders } = await signInWithUser( - "regular@test.com", - "password123", - ); - - const regularClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: regularHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await regularClient.$fetch("/waitlist/approve", { - method: "POST", - body: { email: "user3@test.com" }, - }); - expect(res.error).toBeDefined(); - expect(res.error?.status).toBe(403); - }); - }); - - // ========================================================================= - // ADMIN: REJECT - // ========================================================================= - - describe("admin reject", () => { - it("should reject an entry", async () => { - const rejectedCountBefore = rejectedEntries.length; - - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/reject", { - method: "POST", - body: { email: "user3@test.com" }, - }); - - expect(res.data).toBeDefined(); - expect((res.data as Record).status).toBe("rejected"); - - // Should have called onRejected callback - expect(rejectedEntries.length).toBeGreaterThan(rejectedCountBefore); - }); - - it("should return 404 for non-existent entry", async () => { - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/reject", { - method: "POST", - body: { email: "nonexistent@test.com" }, - }); - expect(res.error).toBeDefined(); - expect(res.error?.status).toBe(404); - }); - }); - - // ========================================================================= - // VERIFY INVITE CODE - // ========================================================================= - - describe("verify invite code", () => { - it("should verify a valid invite code", async () => { - // user1@test.com was approved earlier and should have an invite code - const email = sentEmails.find((e) => e.email === "user1@test.com"); - expect(email).toBeDefined(); - - const res = await client.waitlist.verifyInvite({ - inviteCode: email?.inviteCode ?? "", - }); - expect(res.data).toBeDefined(); - expect(res.data?.valid).toBe(true); - expect(res.data?.email).toBe("user1@test.com"); - }); - - it("should reject invalid invite code", async () => { - const res = await client.waitlist.verifyInvite({ - inviteCode: "invalid-code-that-does-not-exist", - }); - expect(res.data).toBeDefined(); - expect(res.data?.valid).toBe(false); - }); - }); - - // ========================================================================= - // REGISTRATION BLOCKING - // ========================================================================= - - describe("registration blocking", () => { - it("should block signup for unapproved email", async () => { - const res = await client.signUp.email({ - email: "notapproved@test.com", - password: "password123", - name: "Not Approved", - }); - expect(res.error).toBeDefined(); - }); - - it("should allow signup for approved email", async () => { - // Join and approve a new user - await client.waitlist.join({ email: "signuptest@test.com" }); - - // Approve via admin - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - await adminClient.$fetch("/waitlist/approve", { - method: "POST", - body: { email: "signuptest@test.com" }, - }); - - const res = await client.signUp.email({ - email: "signuptest@test.com", - password: "password123", - name: "Signup Test", - }); - expect(res.data).toBeDefined(); - expect(res.data?.user).toBeDefined(); - }); - - it("should mark entry as registered after signup", async () => { - // signuptest@test.com was approved and signed up above - const entry = await ctx.adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: "signuptest@test.com" }], - }) as Record; - const status = await client.waitlist.status({ - query: { token: entry.lookupToken as string }, - }); - expect((status.data as Record)?.status).toBe( - "registered", - ); - }); - - it("should allow existing users to log in", async () => { - // signuptest@test.com was registered above; logging in should work - const res = await client.signIn.email({ - email: "signuptest@test.com", - password: "password123", - }); - expect(res.data).toBeDefined(); - expect(res.data?.user).toBeDefined(); - }); - }); - - // ========================================================================= - // ADMIN: BULK APPROVE - // ========================================================================= - - describe("bulk approve", () => { - it("should bulk approve by email list", async () => { - await client.waitlist.join({ email: "bulk1@test.com" }); - await client.waitlist.join({ email: "bulk2@test.com" }); - - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/bulk-approve", { - method: "POST", - body: { emails: ["bulk1@test.com", "bulk2@test.com"] }, - }); - - expect(res.data).toBeDefined(); - expect((res.data as Record).approved).toBe(2); - - // Verify they are approved - const bulk1Entry = await ctx.adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: "bulk1@test.com" }], - }) as Record; - const status1 = await client.waitlist.status({ - query: { token: bulk1Entry.lookupToken as string }, - }); - expect((status1.data as Record)?.status).toBe( - "approved", - ); - - const bulk2Entry = await ctx.adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: "bulk2@test.com" }], - }) as Record; - const status2 = await client.waitlist.status({ - query: { token: bulk2Entry.lookupToken as string }, - }); - expect((status2.data as Record)?.status).toBe( - "approved", - ); - }); - - it("should bulk approve by count", async () => { - await client.waitlist.join({ email: "bulk3@test.com" }); - await client.waitlist.join({ email: "bulk4@test.com" }); - - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/bulk-approve", { - method: "POST", - body: { count: 2 }, - }); - - expect(res.data).toBeDefined(); - // Should approve up to 2 pending entries - expect( - (res.data as Record).approved, - ).toBeGreaterThanOrEqual(0); - }); - }); - - // ========================================================================= - // ADMIN: LIST & STATS - // ========================================================================= - - describe("admin list and stats", () => { - it("should list waitlist entries", async () => { - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/list", { - method: "GET", - }); - - expect(res.data).toBeDefined(); - const data = res.data as Record; - expect(data.entries).toBeDefined(); - expect(data.total).toBeDefined(); - expect(data.page).toBeDefined(); - expect(data.totalPages).toBeDefined(); - }); - - it("should filter waitlist by status", async () => { - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/list", { - method: "GET", - query: { status: "approved" }, - }); - - expect(res.data).toBeDefined(); - const entries = (res.data as Record).entries as Array< - Record - >; - for (const entry of entries) { - expect(entry.status).toBe("approved"); - } - }); - - it("should get waitlist stats", async () => { - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - const res = await adminClient.$fetch("/waitlist/stats", { - method: "GET", - }); - - expect(res.data).toBeDefined(); - const data = res.data as Record; - expect(data.total).toBeDefined(); - expect(data.pending).toBeDefined(); - expect(data.approved).toBeDefined(); - expect(data.rejected).toBeDefined(); - expect(data.registered).toBeDefined(); - expect(typeof data.total).toBe("number"); - }); - }); -}); +import type { WaitlistEntry } from '../types' +import { createAuthClient } from 'better-auth/client' +import { admin } from 'better-auth/plugins/admin' +import { anonymous } from 'better-auth/plugins/anonymous' +import { getTestInstance } from 'better-auth/test' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { waitlistClient } from '../client' +import { waitlist } from '../index' + +describe('waitlist plugin', async () => { + const joinedEntries: WaitlistEntry[] = [] + const approvedEntries: WaitlistEntry[] = [] + const rejectedEntries: WaitlistEntry[] = [] + const sentEmails: Array<{ + email: string + inviteCode: string + expiresAt: Date + }> = [] + + const { client, auth, signInWithUser, customFetchImpl } + = await getTestInstance( + { + plugins: [ + waitlist({ + onJoinWaitlist: async (entry) => { + joinedEntries.push(entry) + }, + onApproved: async (entry) => { + approvedEntries.push(entry) + }, + onRejected: async (entry) => { + rejectedEntries.push(entry) + }, + sendInviteEmail: async (data) => { + sentEmails.push(data) + }, + adminRoles: ['admin'], + }), + admin({ + defaultRole: 'user', + }), + ], + databaseHooks: { + user: { + create: { + before: async (user) => { + if (user.name === 'Admin') { + return { + data: { + ...user, + role: 'admin', + }, + } + } + }, + }, + }, + }, + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + // Helper: create an admin user and sign in, returning session headers + async function createAdminAndSignIn() { + const email = 'admin@test.com' + const password = 'admin123456' + + // Sign up the admin user (name "Admin" triggers the databaseHook above) + // But the waitlist hook will block signup for non-approved emails + // So we first add the admin to the waitlist and approve them via the adapter + await auth.api.signUpEmail({ + body: { + email, + password, + name: 'Admin', + }, + }) + + const { headers } = await signInWithUser(email, password) + return headers + } + + // We need the admin to be created, but the waitlist blocks user creation + // for non-approved emails via databaseHooks. Let's pre-approve the admin + // email via the adapter directly before creating the admin user. + const ctx = await auth.$context + await ctx.adapter.create({ + model: 'waitlist', + data: { + email: 'admin@test.com', + status: 'approved', + lookupToken: crypto.randomUUID(), + inviteCode: null, + inviteExpiresAt: null, + referredBy: null, + metadata: null, + approvedAt: new Date(), + rejectedAt: null, + registeredAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + + let adminHeaders: Headers + + it('should setup admin user', async () => { + adminHeaders = await createAdminAndSignIn() + expect(adminHeaders.get('cookie')).toBeDefined() + }) + + // ========================================================================= + // JOIN WAITLIST + // ========================================================================= + + // Store lookup tokens for status checks + const lookupTokens: Record = {} + + describe('join waitlist', () => { + it('should add email to waitlist with pending status', async () => { + const res = await client.waitlist.join({ + email: 'user1@test.com', + }) + expect(res.data).toBeDefined() + expect(res.data?.status).toBe('pending') + expect(res.data?.email).toBe('user1@test.com') + expect((res.data as Record)?.lookupToken).toBeDefined() + lookupTokens['user1@test.com'] = (res.data as Record).lookupToken as string + }) + + it('should reject duplicate email', async () => { + const res = await client.waitlist.join({ + email: 'user1@test.com', + }) + expect(res.error).toBeDefined() + expect(res.error?.status).toBe(400) + }) + + it('should normalize email to lowercase', async () => { + const res = await client.waitlist.join({ + email: 'User2@Test.COM', + }) + expect(res.data).toBeDefined() + expect(res.data?.email).toBe('user2@test.com') + }) + + it('should include referredBy', async () => { + const res = await client.waitlist.join({ + email: 'user3@test.com', + referredBy: 'friend123', + }) + expect(res.data).toBeDefined() + expect(res.data?.status).toBe('pending') + }) + + it('should call onJoinWaitlist callback', () => { + expect(joinedEntries.length).toBeGreaterThan(0) + const found = joinedEntries.find(e => e.email === 'user1@test.com') + expect(found).toBeDefined() + }) + + it('should reject invalid email format', async () => { + const res = await client.waitlist.join({ + email: 'not-an-email', + }) + expect(res.error).toBeDefined() + }) + }) + + // ========================================================================= + // CHECK STATUS + // ========================================================================= + + describe('check status', () => { + it('should return correct status for existing token', async () => { + const res = await client.waitlist.status({ + query: { token: lookupTokens['user1@test.com']! }, + }) + expect(res.data).toBeDefined() + expect((res.data as Record)?.status).toBe('pending') + }) + + it('should return 404 for unknown token', async () => { + const res = await client.waitlist.status({ + query: { token: 'nonexistent-token' }, + }) + expect(res.error).toBeDefined() + expect(res.error?.status).toBe(404) + }) + }) + + // ========================================================================= + // ADMIN: APPROVE + // ========================================================================= + + describe('admin approve', () => { + it('should approve an entry and generate invite code', async () => { + const approvedCountBefore = approvedEntries.length + const emailCountBefore = sentEmails.length + + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/approve', { + method: 'POST', + body: { email: 'user1@test.com' }, + }) + + expect(res.data).toBeDefined() + expect((res.data as Record).status).toBe('approved') + expect((res.data as Record).inviteCode).toBeDefined() + + // Should have called onApproved callback + expect(approvedEntries.length).toBeGreaterThan(approvedCountBefore) + + // Should have called sendInviteEmail + expect(sentEmails.length).toBeGreaterThan(emailCountBefore) + const lastEmail = sentEmails.at(-1) + expect(lastEmail).toBeDefined() + expect(lastEmail?.email).toBe('user1@test.com') + expect(lastEmail?.inviteCode).toBeDefined() + expect(lastEmail?.expiresAt).toBeInstanceOf(Date) + }) + + it('should reject approve for already registered entry', async () => { + // First approve user2 and then simulate registration + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + await adminClient.$fetch('/waitlist/approve', { + method: 'POST', + body: { email: 'user2@test.com' }, + }) + + // Register user2 (signup should work since they're approved) + await auth.api.signUpEmail({ + body: { + email: 'user2@test.com', + password: 'password123', + name: 'User Two', + }, + }) + + // Now trying to approve again should fail (status is "registered") + const res = await adminClient.$fetch('/waitlist/approve', { + method: 'POST', + body: { email: 'user2@test.com' }, + }) + expect(res.error).toBeDefined() + }) + + it('should return 404 for non-existent entry', async () => { + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/approve', { + method: 'POST', + body: { email: 'nonexistent@test.com' }, + }) + expect(res.error).toBeDefined() + expect(res.error?.status).toBe(404) + }) + + it('should deny non-admin users', async () => { + // Create a regular user (first approve on waitlist, then sign up) + await client.waitlist.join({ email: 'regular@test.com' }) + + // Approve via adapter directly + await ctx.adapter.update({ + model: 'waitlist', + where: [{ field: 'email', value: 'regular@test.com' }], + update: { + status: 'approved', + approvedAt: new Date(), + updatedAt: new Date(), + }, + }) + + await auth.api.signUpEmail({ + body: { + email: 'regular@test.com', + password: 'password123', + name: 'Regular User', + }, + }) + + const { headers: regularHeaders } = await signInWithUser( + 'regular@test.com', + 'password123', + ) + + const regularClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: regularHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await regularClient.$fetch('/waitlist/approve', { + method: 'POST', + body: { email: 'user3@test.com' }, + }) + expect(res.error).toBeDefined() + expect(res.error?.status).toBe(403) + }) + }) + + // ========================================================================= + // ADMIN: REJECT + // ========================================================================= + + describe('admin reject', () => { + it('should reject an entry', async () => { + const rejectedCountBefore = rejectedEntries.length + + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/reject', { + method: 'POST', + body: { email: 'user3@test.com' }, + }) + + expect(res.data).toBeDefined() + expect((res.data as Record).status).toBe('rejected') + + // Should have called onRejected callback + expect(rejectedEntries.length).toBeGreaterThan(rejectedCountBefore) + }) + + it('should return 404 for non-existent entry', async () => { + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/reject', { + method: 'POST', + body: { email: 'nonexistent@test.com' }, + }) + expect(res.error).toBeDefined() + expect(res.error?.status).toBe(404) + }) + }) + + // ========================================================================= + // VERIFY INVITE CODE + // ========================================================================= + + describe('verify invite code', () => { + it('should verify a valid invite code', async () => { + // user1@test.com was approved earlier and should have an invite code + const email = sentEmails.find(e => e.email === 'user1@test.com') + expect(email).toBeDefined() + + const res = await client.waitlist.verifyInvite({ + inviteCode: email?.inviteCode ?? '', + }) + expect(res.data).toBeDefined() + expect(res.data?.valid).toBe(true) + expect(res.data?.email).toBe('user1@test.com') + }) + + it('should reject invalid invite code', async () => { + const res = await client.waitlist.verifyInvite({ + inviteCode: 'invalid-code-that-does-not-exist', + }) + expect(res.data).toBeDefined() + expect(res.data?.valid).toBe(false) + }) + }) + + // ========================================================================= + // REGISTRATION BLOCKING + // ========================================================================= + + describe('registration blocking', () => { + it('should block signup for unapproved email', async () => { + const res = await client.signUp.email({ + email: 'notapproved@test.com', + password: 'password123', + name: 'Not Approved', + }) + expect(res.error).toBeDefined() + }) + + it('should allow signup for approved email', async () => { + // Join and approve a new user + await client.waitlist.join({ email: 'signuptest@test.com' }) + + // Approve via admin + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + await adminClient.$fetch('/waitlist/approve', { + method: 'POST', + body: { email: 'signuptest@test.com' }, + }) + + const res = await client.signUp.email({ + email: 'signuptest@test.com', + password: 'password123', + name: 'Signup Test', + }) + expect(res.data).toBeDefined() + expect(res.data?.user).toBeDefined() + }) + + it('should mark entry as registered after signup', async () => { + // signuptest@test.com was approved and signed up above + const entry = await ctx.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: 'signuptest@test.com' }], + }) as Record + const status = await client.waitlist.status({ + query: { token: entry.lookupToken as string }, + }) + expect((status.data as Record)?.status).toBe( + 'registered', + ) + }) + + it('should allow existing users to log in', async () => { + // signuptest@test.com was registered above; logging in should work + const res = await client.signIn.email({ + email: 'signuptest@test.com', + password: 'password123', + }) + expect(res.data).toBeDefined() + expect(res.data?.user).toBeDefined() + }) + }) + + // ========================================================================= + // ADMIN: BULK APPROVE + // ========================================================================= + + describe('bulk approve', () => { + it('should bulk approve by email list', async () => { + await client.waitlist.join({ email: 'bulk1@test.com' }) + await client.waitlist.join({ email: 'bulk2@test.com' }) + + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/bulk-approve', { + method: 'POST', + body: { emails: ['bulk1@test.com', 'bulk2@test.com'] }, + }) + + expect(res.data).toBeDefined() + expect((res.data as Record).approved).toBe(2) + + // Verify they are approved + const bulk1Entry = await ctx.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: 'bulk1@test.com' }], + }) as Record + const status1 = await client.waitlist.status({ + query: { token: bulk1Entry.lookupToken as string }, + }) + expect((status1.data as Record)?.status).toBe( + 'approved', + ) + + const bulk2Entry = await ctx.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: 'bulk2@test.com' }], + }) as Record + const status2 = await client.waitlist.status({ + query: { token: bulk2Entry.lookupToken as string }, + }) + expect((status2.data as Record)?.status).toBe( + 'approved', + ) + }) + + it('should bulk approve by count', async () => { + await client.waitlist.join({ email: 'bulk3@test.com' }) + await client.waitlist.join({ email: 'bulk4@test.com' }) + + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/bulk-approve', { + method: 'POST', + body: { count: 2 }, + }) + + expect(res.data).toBeDefined() + // Should approve up to 2 pending entries + expect( + (res.data as Record).approved, + ).toBeGreaterThanOrEqual(0) + }) + }) + + // ========================================================================= + // ADMIN: LIST & STATS + // ========================================================================= + + describe('admin list and stats', () => { + it('should list waitlist entries', async () => { + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/list', { + method: 'GET', + }) + + expect(res.data).toBeDefined() + const data = res.data as Record + expect(data.entries).toBeDefined() + expect(data.total).toBeDefined() + expect(data.page).toBeDefined() + expect(data.totalPages).toBeDefined() + }) + + it('should filter waitlist by status', async () => { + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/list', { + method: 'GET', + query: { status: 'approved' }, + }) + + expect(res.data).toBeDefined() + const entries = (res.data as Record).entries as Array< + Record + > + for (const entry of entries) { + expect(entry.status).toBe('approved') + } + }) + + it('should get waitlist stats', async () => { + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + const res = await adminClient.$fetch('/waitlist/stats', { + method: 'GET', + }) + + expect(res.data).toBeDefined() + const data = res.data as Record + expect(data.total).toBeDefined() + expect(data.pending).toBeDefined() + expect(data.approved).toBeDefined() + expect(data.rejected).toBeDefined() + expect(data.registered).toBeDefined() + expect(typeof data.total).toBe('number') + }) + }) +}) // ========================================================================= // AUTO-APPROVE // ========================================================================= -describe("waitlist plugin - auto approve", async () => { - const autoApproveEmails: Array<{ - email: string; - inviteCode: string; - expiresAt: Date; - }> = []; - const approvedEntries: WaitlistEntry[] = []; - - const { client } = await getTestInstance( - { - plugins: [ - waitlist({ - autoApprove: true, - onApproved: async (entry) => { - approvedEntries.push(entry); - }, - sendInviteEmail: async (data) => { - autoApproveEmails.push(data); - }, - }), - ], - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - it("should auto-approve entries on join", async () => { - const res = await client.waitlist.join({ - email: "autoapprove@test.com", - }); - expect(res.data).toBeDefined(); - expect(res.data?.status).toBe("approved"); - }); - - it("should send invite email on auto-approve", () => { - const email = autoApproveEmails.find( - (e) => e.email === "autoapprove@test.com", - ); - expect(email).toBeDefined(); - expect(email?.inviteCode).toBeDefined(); - }); - - it("should call onApproved callback on auto-approve", () => { - const found = approvedEntries.find( - (e) => e.email === "autoapprove@test.com", - ); - expect(found).toBeDefined(); - }); - - it("should allow signup after auto-approve", async () => { - const res = await client.signUp.email({ - email: "autoapprove@test.com", - password: "password123", - name: "Auto Approved", - }); - expect(res.data).toBeDefined(); - expect(res.data?.user).toBeDefined(); - }); -}); +describe('waitlist plugin - auto approve', async () => { + const autoApproveEmails: Array<{ + email: string + inviteCode: string + expiresAt: Date + }> = [] + const approvedEntries: WaitlistEntry[] = [] + + const { client } = await getTestInstance( + { + plugins: [ + waitlist({ + autoApprove: true, + onApproved: async (entry) => { + approvedEntries.push(entry) + }, + sendInviteEmail: async (data) => { + autoApproveEmails.push(data) + }, + }), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + it('should auto-approve entries on join', async () => { + const res = await client.waitlist.join({ + email: 'autoapprove@test.com', + }) + expect(res.data).toBeDefined() + expect(res.data?.status).toBe('approved') + }) + + it('should send invite email on auto-approve', () => { + const email = autoApproveEmails.find( + e => e.email === 'autoapprove@test.com', + ) + expect(email).toBeDefined() + expect(email?.inviteCode).toBeDefined() + }) + + it('should call onApproved callback on auto-approve', () => { + const found = approvedEntries.find( + e => e.email === 'autoapprove@test.com', + ) + expect(found).toBeDefined() + }) + + it('should allow signup after auto-approve', async () => { + const res = await client.signUp.email({ + email: 'autoapprove@test.com', + password: 'password123', + name: 'Auto Approved', + }) + expect(res.data).toBeDefined() + expect(res.data?.user).toBeDefined() + }) +}) // ========================================================================= // AUTO-APPROVE (CONDITIONAL) // ========================================================================= -describe("waitlist plugin - conditional auto approve", async () => { - const { client } = await getTestInstance( - { - plugins: [ - waitlist({ - autoApprove: (email) => email.endsWith("@vip.com"), - }), - ], - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - it("should auto-approve VIP email", async () => { - const res = await client.waitlist.join({ - email: "user@vip.com", - }); - expect(res.data?.status).toBe("approved"); - }); - - it("should not auto-approve non-VIP email", async () => { - const res = await client.waitlist.join({ - email: "user@regular.com", - }); - expect(res.data?.status).toBe("pending"); - }); -}); +describe('waitlist plugin - conditional auto approve', async () => { + const { client } = await getTestInstance( + { + plugins: [ + waitlist({ + autoApprove: email => email.endsWith('@vip.com'), + }), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + it('should auto-approve VIP email', async () => { + const res = await client.waitlist.join({ + email: 'user@vip.com', + }) + expect(res.data?.status).toBe('approved') + }) + + it('should not auto-approve non-VIP email', async () => { + const res = await client.waitlist.join({ + email: 'user@regular.com', + }) + expect(res.data?.status).toBe('pending') + }) +}) // ========================================================================= // MAX WAITLIST SIZE // ========================================================================= -describe("waitlist plugin - max size", async () => { - const { client } = await getTestInstance( - { - plugins: [ - waitlist({ - maxWaitlistSize: 2, - }), - ], - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - it("should allow entries up to the limit", async () => { - const res1 = await client.waitlist.join({ email: "max1@test.com" }); - expect(res1.data).toBeDefined(); - const res2 = await client.waitlist.join({ email: "max2@test.com" }); - expect(res2.data).toBeDefined(); - }); - - it("should reject when waitlist is full", async () => { - const res = await client.waitlist.join({ email: "max3@test.com" }); - expect(res.error).toBeDefined(); - expect(res.error?.status).toBe(400); - }); -}); +describe('waitlist plugin - max size', async () => { + const { client } = await getTestInstance( + { + plugins: [ + waitlist({ + maxWaitlistSize: 2, + }), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + it('should allow entries up to the limit', async () => { + const res1 = await client.waitlist.join({ email: 'max1@test.com' }) + expect(res1.data).toBeDefined() + const res2 = await client.waitlist.join({ email: 'max2@test.com' }) + expect(res2.data).toBeDefined() + }) + + it('should reject when waitlist is full', async () => { + const res = await client.waitlist.join({ email: 'max3@test.com' }) + expect(res.error).toBeDefined() + expect(res.error?.status).toBe(400) + }) +}) // ========================================================================= // DISABLED WAITLIST // ========================================================================= -describe("waitlist plugin - disabled", async () => { - const { client } = await getTestInstance( - { - plugins: [ - waitlist({ - enabled: false, - }), - ], - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - it("should allow signup when waitlist is disabled", async () => { - const res = await client.signUp.email({ - email: "nofence@test.com", - password: "password123", - name: "No Fence", - }); - expect(res.data).toBeDefined(); - expect(res.data?.user).toBeDefined(); - }); -}); +describe('waitlist plugin - disabled', async () => { + const { client } = await getTestInstance( + { + plugins: [ + waitlist({ + enabled: false, + }), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + it('should allow signup when waitlist is disabled', async () => { + const res = await client.signUp.email({ + email: 'nofence@test.com', + password: 'password123', + name: 'No Fence', + }) + expect(res.data).toBeDefined() + expect(res.data?.user).toBeDefined() + }) +}) // ========================================================================= // REQUIRE INVITE CODE // ========================================================================= -describe("waitlist plugin - require invite code", async () => { - const sentEmails: Array<{ - email: string; - inviteCode: string; - expiresAt: Date; - }> = []; - - const { client, auth, signInWithUser, customFetchImpl } = - await getTestInstance( - { - plugins: [ - waitlist({ - requireInviteCode: true, - sendInviteEmail: async (data) => { - sentEmails.push(data); - }, - adminRoles: ["admin"], - }), - admin({ - defaultRole: "user", - }), - ], - databaseHooks: { - user: { - create: { - before: async (user) => { - if (user.name === "Admin RIC") { - return { - data: { - ...user, - role: "admin", - }, - }; - } - }, - }, - }, - }, - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - // Pre-approve admin via adapter - const ctx = await auth.$context; - await ctx.adapter.create({ - model: "waitlist", - data: { - email: "admin-ric@test.com", - status: "approved", - lookupToken: crypto.randomUUID(), - inviteCode: "admin-invite-code", - inviteExpiresAt: new Date(Date.now() + 172800 * 1000), - referredBy: null, - metadata: null, - approvedAt: new Date(), - rejectedAt: null, - registeredAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - await customFetchImpl("http://localhost:3000/api/auth/sign-up/email", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: "admin-ric@test.com", - password: "admin123456", - name: "Admin RIC", - inviteCode: "admin-invite-code", - }), - }); - - const { headers: adminHeaders } = await signInWithUser( - "admin-ric@test.com", - "admin123456", - ); - - it("should block signup without invite code", async () => { - await client.waitlist.join({ email: "nocode@test.com" }); - - // Approve via admin - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - await adminClient.$fetch("/waitlist/approve", { - method: "POST", - body: { email: "nocode@test.com" }, - }); - - // Try to sign up without invite code - const res = await client.signUp.email({ - email: "nocode@test.com", - password: "password123", - name: "No Code User", - }); - expect(res.error).toBeDefined(); - expect(res.error?.status).toBe(403); - }); - - it("should allow signup with valid invite code", async () => { - await client.waitlist.join({ email: "withcode@test.com" }); - - const adminClient = createAuthClient({ - fetchOptions: { - customFetchImpl, - headers: adminHeaders, - }, - plugins: [waitlistClient()], - baseURL: "http://localhost:3000", - }); - - await adminClient.$fetch("/waitlist/approve", { - method: "POST", - body: { email: "withcode@test.com" }, - }); - - // Get the invite code from sent emails - const email = sentEmails.find((e) => e.email === "withcode@test.com"); - expect(email).toBeDefined(); - - // Sign up with the invite code in the body - const fetchRes = await customFetchImpl( - "http://localhost:3000/api/auth/sign-up/email", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: "withcode@test.com", - password: "password123", - name: "With Code User", - inviteCode: email?.inviteCode ?? "", - }), - }, - ); - - expect(fetchRes.status).toBe(200); - }); - - it("should reject signup with invalid invite code", async () => { - await client.waitlist.join({ email: "badcode@test.com" }); - - const fetchRes = await customFetchImpl( - "http://localhost:3000/api/auth/sign-up/email", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: "badcode@test.com", - password: "password123", - name: "Bad Code User", - inviteCode: "totally-invalid-code", - }), - }, - ); - - expect(fetchRes.status).toBe(403); - }); -}); +describe('waitlist plugin - require invite code', async () => { + const sentEmails: Array<{ + email: string + inviteCode: string + expiresAt: Date + }> = [] + + const { client, auth, signInWithUser, customFetchImpl } + = await getTestInstance( + { + plugins: [ + waitlist({ + requireInviteCode: true, + sendInviteEmail: async (data) => { + sentEmails.push(data) + }, + adminRoles: ['admin'], + }), + admin({ + defaultRole: 'user', + }), + ], + databaseHooks: { + user: { + create: { + before: async (user) => { + if (user.name === 'Admin RIC') { + return { + data: { + ...user, + role: 'admin', + }, + } + } + }, + }, + }, + }, + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + // Pre-approve admin via adapter + const ctx = await auth.$context + await ctx.adapter.create({ + model: 'waitlist', + data: { + email: 'admin-ric@test.com', + status: 'approved', + lookupToken: crypto.randomUUID(), + inviteCode: 'admin-invite-code', + inviteExpiresAt: new Date(Date.now() + 172800 * 1000), + referredBy: null, + metadata: null, + approvedAt: new Date(), + rejectedAt: null, + registeredAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + + await customFetchImpl('http://localhost:3000/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'admin-ric@test.com', + password: 'admin123456', + name: 'Admin RIC', + inviteCode: 'admin-invite-code', + }), + }) + + const { headers: adminHeaders } = await signInWithUser( + 'admin-ric@test.com', + 'admin123456', + ) + + it('should block signup without invite code', async () => { + await client.waitlist.join({ email: 'nocode@test.com' }) + + // Approve via admin + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + await adminClient.$fetch('/waitlist/approve', { + method: 'POST', + body: { email: 'nocode@test.com' }, + }) + + // Try to sign up without invite code + const res = await client.signUp.email({ + email: 'nocode@test.com', + password: 'password123', + name: 'No Code User', + }) + expect(res.error).toBeDefined() + expect(res.error?.status).toBe(403) + }) + + it('should allow signup with valid invite code', async () => { + await client.waitlist.join({ email: 'withcode@test.com' }) + + const adminClient = createAuthClient({ + fetchOptions: { + customFetchImpl, + headers: adminHeaders, + }, + plugins: [waitlistClient()], + baseURL: 'http://localhost:3000', + }) + + await adminClient.$fetch('/waitlist/approve', { + method: 'POST', + body: { email: 'withcode@test.com' }, + }) + + // Get the invite code from sent emails + const email = sentEmails.find(e => e.email === 'withcode@test.com') + expect(email).toBeDefined() + + // Sign up with the invite code in the body + const fetchRes = await customFetchImpl( + 'http://localhost:3000/api/auth/sign-up/email', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'withcode@test.com', + password: 'password123', + name: 'With Code User', + inviteCode: email?.inviteCode ?? '', + }), + }, + ) + + expect(fetchRes.status).toBe(200) + }) + + it('should reject signup with invalid invite code', async () => { + await client.waitlist.join({ email: 'badcode@test.com' }) + + const fetchRes = await customFetchImpl( + 'http://localhost:3000/api/auth/sign-up/email', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'badcode@test.com', + password: 'password123', + name: 'Bad Code User', + inviteCode: 'totally-invalid-code', + }), + }, + ) + + expect(fetchRes.status).toBe(403) + }) +}) // ========================================================================= // INVITE CODE EXPIRATION // ========================================================================= -describe("waitlist plugin - invite code expiration", async () => { - afterEach(() => { - vi.useRealTimers(); - }); - - const sentEmails: Array<{ - email: string; - inviteCode: string; - expiresAt: Date; - }> = []; - - const { client, auth } = await getTestInstance( - { - plugins: [ - waitlist({ - inviteCodeExpiration: 60, // 60 seconds for easy testing - sendInviteEmail: async (data) => { - sentEmails.push(data); - }, - adminRoles: ["admin"], - }), - admin({ - defaultRole: "user", - }), - ], - databaseHooks: { - user: { - create: { - before: async (user) => { - if (user.name === "Admin Exp") { - return { - data: { - ...user, - role: "admin", - }, - }; - } - }, - }, - }, - }, - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - // Pre-approve admin - const ctx = await auth.$context; - await ctx.adapter.create({ - model: "waitlist", - data: { - email: "admin-exp@test.com", - status: "approved", - lookupToken: crypto.randomUUID(), - inviteCode: null, - inviteExpiresAt: null, - referredBy: null, - metadata: null, - approvedAt: new Date(), - rejectedAt: null, - registeredAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - await auth.api.signUpEmail({ - body: { - email: "admin-exp@test.com", - password: "admin123456", - name: "Admin Exp", - }, - }); - - it("should reject expired invite code via verify endpoint", async () => { - await client.waitlist.join({ email: "expire1@test.com" }); - - // Approve via adapter to generate invite code with short expiration - const inviteCode = crypto.randomUUID(); - const inviteExpiresAt = new Date(Date.now() + 60 * 1000); // 60 seconds - - await ctx.adapter.update({ - model: "waitlist", - where: [{ field: "email", value: "expire1@test.com" }], - update: { - status: "approved", - inviteCode, - inviteExpiresAt, - approvedAt: new Date(), - updatedAt: new Date(), - }, - }); - - // Verify it works before expiration - const validRes = await client.waitlist.verifyInvite({ inviteCode }); - expect(validRes.data?.valid).toBe(true); - - // Advance time past expiration - vi.useFakeTimers(); - await vi.advanceTimersByTimeAsync(61 * 1000); - - // Now it should be expired - const expiredRes = await client.waitlist.verifyInvite({ inviteCode }); - expect(expiredRes.data?.valid).toBe(false); - }); - - it("should reject expired invite code during registration", async () => { - vi.useRealTimers(); // Reset from previous test - - await client.waitlist.join({ email: "expire2@test.com" }); - - // Create an entry with an already-expired invite code - const inviteCode = crypto.randomUUID(); - const inviteExpiresAt = new Date(Date.now() - 1000); // Already expired - - await ctx.adapter.update({ - model: "waitlist", - where: [{ field: "email", value: "expire2@test.com" }], - update: { - status: "approved", - inviteCode, - inviteExpiresAt, - approvedAt: new Date(), - updatedAt: new Date(), - }, - }); - - const verifyRes = await client.waitlist.verifyInvite({ inviteCode }); - expect(verifyRes.data?.valid).toBe(false); - }); -}); +describe('waitlist plugin - invite code expiration', async () => { + afterEach(() => { + vi.useRealTimers() + }) + + const sentEmails: Array<{ + email: string + inviteCode: string + expiresAt: Date + }> = [] + + const { client, auth } = await getTestInstance( + { + plugins: [ + waitlist({ + inviteCodeExpiration: 60, // 60 seconds for easy testing + sendInviteEmail: async (data) => { + sentEmails.push(data) + }, + adminRoles: ['admin'], + }), + admin({ + defaultRole: 'user', + }), + ], + databaseHooks: { + user: { + create: { + before: async (user) => { + if (user.name === 'Admin Exp') { + return { + data: { + ...user, + role: 'admin', + }, + } + } + }, + }, + }, + }, + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + // Pre-approve admin + const ctx = await auth.$context + await ctx.adapter.create({ + model: 'waitlist', + data: { + email: 'admin-exp@test.com', + status: 'approved', + lookupToken: crypto.randomUUID(), + inviteCode: null, + inviteExpiresAt: null, + referredBy: null, + metadata: null, + approvedAt: new Date(), + rejectedAt: null, + registeredAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + + await auth.api.signUpEmail({ + body: { + email: 'admin-exp@test.com', + password: 'admin123456', + name: 'Admin Exp', + }, + }) + + it('should reject expired invite code via verify endpoint', async () => { + await client.waitlist.join({ email: 'expire1@test.com' }) + + // Approve via adapter to generate invite code with short expiration + const inviteCode = crypto.randomUUID() + const inviteExpiresAt = new Date(Date.now() + 60 * 1000) // 60 seconds + + await ctx.adapter.update({ + model: 'waitlist', + where: [{ field: 'email', value: 'expire1@test.com' }], + update: { + status: 'approved', + inviteCode, + inviteExpiresAt, + approvedAt: new Date(), + updatedAt: new Date(), + }, + }) + + // Verify it works before expiration + const validRes = await client.waitlist.verifyInvite({ inviteCode }) + expect(validRes.data?.valid).toBe(true) + + // Advance time past expiration + vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(61 * 1000) + + // Now it should be expired + const expiredRes = await client.waitlist.verifyInvite({ inviteCode }) + expect(expiredRes.data?.valid).toBe(false) + }) + + it('should reject expired invite code during registration', async () => { + vi.useRealTimers() // Reset from previous test + + await client.waitlist.join({ email: 'expire2@test.com' }) + + // Create an entry with an already-expired invite code + const inviteCode = crypto.randomUUID() + const inviteExpiresAt = new Date(Date.now() - 1000) // Already expired + + await ctx.adapter.update({ + model: 'waitlist', + where: [{ field: 'email', value: 'expire2@test.com' }], + update: { + status: 'approved', + inviteCode, + inviteExpiresAt, + approvedAt: new Date(), + updatedAt: new Date(), + }, + }) + + const verifyRes = await client.waitlist.verifyInvite({ inviteCode }) + expect(verifyRes.data?.valid).toBe(false) + }) +}) // ========================================================================= // ANONYMOUS USERS (skipAnonymous) // ========================================================================= -describe("waitlist plugin - anonymous users with skipAnonymous", async () => { - const { customFetchImpl } = await getTestInstance( - { - plugins: [ - waitlist({ - skipAnonymous: true, - }), - anonymous(), - ], - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - it("should allow anonymous sign-in when skipAnonymous is true", async () => { - const res = await customFetchImpl( - "http://localhost:3000/api/auth/sign-in/anonymous", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }, - ); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.user).toBeDefined(); - }); -}); - -describe("waitlist plugin - anonymous users without skipAnonymous", async () => { - const { customFetchImpl } = await getTestInstance( - { - plugins: [ - waitlist({ - skipAnonymous: false, - }), - anonymous(), - ], - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - it("should block anonymous sign-in when skipAnonymous is false", async () => { - // The anonymous sign-in goes through /sign-in/anonymous which is in - // the default intercept paths. Without skipAnonymous, it should be blocked. - // The hook intercepts the path but since there is no email in body, - // the middleware falls through. However, the databaseHook on user create - // will check for a waitlist approval, and since anonymous users have a - // generated email with no waitlist entry, creation should be blocked. - const res = await customFetchImpl( - "http://localhost:3000/api/auth/sign-in/anonymous", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }, - ); - // The databaseHook returns false for unapproved emails, - // which prevents user creation - expect(res.status).toBeDefined(); - // Status should not be 200 (blocked) or the response body should - // indicate failure. The exact behavior depends on how Better Auth - // handles a `false` return from databaseHooks before callback. - // In practice, returning false from the hook causes the user not - // to be created, which results in a 500 or similar error. - expect(res.status).not.toBe(200); - }); -}); +describe('waitlist plugin - anonymous users with skipAnonymous', async () => { + const { customFetchImpl } = await getTestInstance( + { + plugins: [ + waitlist({ + skipAnonymous: true, + }), + anonymous(), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + it('should allow anonymous sign-in when skipAnonymous is true', async () => { + const res = await customFetchImpl( + 'http://localhost:3000/api/auth/sign-in/anonymous', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.user).toBeDefined() + }) +}) + +describe('waitlist plugin - anonymous users without skipAnonymous', async () => { + const { customFetchImpl } = await getTestInstance( + { + plugins: [ + waitlist({ + skipAnonymous: false, + }), + anonymous(), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + it('should block anonymous sign-in when skipAnonymous is false', async () => { + // The anonymous sign-in goes through /sign-in/anonymous which is in + // the default intercept paths. Without skipAnonymous, it should be blocked. + // The hook intercepts the path but since there is no email in body, + // the middleware falls through. However, the databaseHook on user create + // will check for a waitlist approval, and since anonymous users have a + // generated email with no waitlist entry, creation should be blocked. + const res = await customFetchImpl( + 'http://localhost:3000/api/auth/sign-in/anonymous', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ) + // The databaseHook returns false for unapproved emails, + // which prevents user creation + expect(res.status).toBeDefined() + // Status should not be 200 (blocked) or the response body should + // indicate failure. The exact behavior depends on how Better Auth + // handles a `false` return from databaseHooks before callback. + // In practice, returning false from the hook causes the user not + // to be created, which results in a 500 or similar error. + expect(res.status).not.toBe(200) + }) +}) // ========================================================================= // OAUTH FLOW (databaseHooks blocking) // ========================================================================= -describe("waitlist plugin - OAuth/databaseHook blocking", async () => { - const { auth } = await getTestInstance( - { - plugins: [ - waitlist({ - enabled: true, - }), - ], - }, - { - disableTestUser: true, - clientOptions: { - plugins: [waitlistClient()], - }, - }, - ); - - it("should block user creation via internalAdapter for unapproved email", async () => { - // Simulate what happens during an OAuth callback: - // The internalAdapter.createUser triggers the databaseHooks - // which check waitlist approval status - let error: unknown = null; - try { - await auth.api.signUpEmail({ - body: { - email: "oauth-unapproved@test.com", - password: "password123", - name: "OAuth User", - }, - }); - } catch (e) { - error = e; - } - - // The databaseHook should block creation by returning false - // This manifests as either an error or a null response - // depending on how Better Auth handles the hook return - expect(error).toBeDefined(); - }); - - it("should allow user creation for pre-approved email", async () => { - const ctx = await auth.$context; - - // Pre-approve via adapter - await ctx.adapter.create({ - model: "waitlist", - data: { - email: "oauth-approved@test.com", - status: "approved", - lookupToken: crypto.randomUUID(), - inviteCode: null, - inviteExpiresAt: null, - referredBy: null, - metadata: null, - approvedAt: new Date(), - rejectedAt: null, - registeredAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - // Now signup should succeed - const result = await auth.api.signUpEmail({ - body: { - email: "oauth-approved@test.com", - password: "password123", - name: "OAuth Approved User", - }, - }); - - expect(result).toBeDefined(); - expect(result.user).toBeDefined(); - expect(result.user.email).toBe("oauth-approved@test.com"); - }); -}); +describe('waitlist plugin - OAuth/databaseHook blocking', async () => { + const { auth } = await getTestInstance( + { + plugins: [ + waitlist({ + enabled: true, + }), + ], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [waitlistClient()], + }, + }, + ) + + it('should block user creation via internalAdapter for unapproved email', async () => { + // Simulate what happens during an OAuth callback: + // The internalAdapter.createUser triggers the databaseHooks + // which check waitlist approval status + let error: unknown = null + try { + await auth.api.signUpEmail({ + body: { + email: 'oauth-unapproved@test.com', + password: 'password123', + name: 'OAuth User', + }, + }) + } + catch (e) { + error = e + } + + // The databaseHook should block creation by returning false + // This manifests as either an error or a null response + // depending on how Better Auth handles the hook return + expect(error).toBeDefined() + }) + + it('should allow user creation for pre-approved email', async () => { + const ctx = await auth.$context + + // Pre-approve via adapter + await ctx.adapter.create({ + model: 'waitlist', + data: { + email: 'oauth-approved@test.com', + status: 'approved', + lookupToken: crypto.randomUUID(), + inviteCode: null, + inviteExpiresAt: null, + referredBy: null, + metadata: null, + approvedAt: new Date(), + rejectedAt: null, + registeredAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + + // Now signup should succeed + const result = await auth.api.signUpEmail({ + body: { + email: 'oauth-approved@test.com', + password: 'password123', + name: 'OAuth Approved User', + }, + }) + + expect(result).toBeDefined() + expect(result.user).toBeDefined() + expect(result.user.email).toBe('oauth-approved@test.com') + }) +}) diff --git a/packages/waitlist/src/client.ts b/packages/waitlist/src/client.ts index 40efcb8..a0f491c 100644 --- a/packages/waitlist/src/client.ts +++ b/packages/waitlist/src/client.ts @@ -1,15 +1,15 @@ -import type { BetterAuthClientPlugin } from "better-auth/client"; -import { useAuthQuery } from "better-auth/client"; -import { atom } from "nanostores"; -import type { waitlist } from "./index"; -import type { WaitlistClientOptions, WaitlistEntry } from "./types"; +import type { BetterAuthClientPlugin } from 'better-auth/client' +import type { waitlist } from './index' +import type { WaitlistClientOptions, WaitlistEntry } from './types' +import { useAuthQuery } from 'better-auth/client' +import { atom } from 'nanostores' interface WaitlistStats { - total: number; - pending: number; - approved: number; - rejected: number; - registered: number; + total: number + pending: number + approved: number + rejected: number + registered: number } /** @@ -35,94 +35,98 @@ interface WaitlistStats { * await auth.waitlist.join({ email: "user@example.com" }); * * // Check status - * const { data } = await auth.waitlist.status({ token: joinResult.lookupToken }); + * const { data } = await auth.waitlist.status({ query: { token: joinResult.lookupToken } }); * ``` */ -export const waitlistClient = (_options?: WaitlistClientOptions) => { - const $waitlistSignal = atom(false); +export function waitlistClient(_options?: WaitlistClientOptions) { + const $waitlistSignal = atom(false) - return { - id: "waitlist", - $InferServerPlugin: {} as ReturnType, - getActions: ($fetch) => ({ - waitlist: { - join: async ( - data: { - email: string; - referredBy?: string; - metadata?: Record; - }, - fetchOptions?: RequestInit, - ) => { - return $fetch("/waitlist/join", { - method: "POST", - body: data, - ...fetchOptions, - }); - }, - status: async (data: { token: string }, fetchOptions?: RequestInit) => { - return $fetch("/waitlist/status", { - method: "GET", - query: data, - ...fetchOptions, - }); - }, - verifyInvite: async ( - data: { inviteCode: string }, - fetchOptions?: RequestInit, - ) => { - return $fetch("/waitlist/verify-invite", { - method: "POST", - body: data, - ...fetchOptions, - }); - }, - }, - $Infer: {} as { - WaitlistEntry: WaitlistEntry; - }, - }), - getAtoms($fetch) { - const waitlistStats = useAuthQuery( - $waitlistSignal, - "/waitlist/stats", - $fetch, - { - method: "GET", - }, - ); - return { - $waitlistSignal, - waitlistStats, - }; - }, - pathMethods: { - "/waitlist/join": "POST", - "/waitlist/status": "GET", - "/waitlist/verify-invite": "POST", - "/waitlist/approve": "POST", - "/waitlist/reject": "POST", - "/waitlist/bulk-approve": "POST", - "/waitlist/list": "GET", - "/waitlist/stats": "GET", - }, - atomListeners: [ - { - matcher(path) { - return ( - path === "/waitlist/approve" || - path === "/waitlist/reject" || - path === "/waitlist/bulk-approve" - ); - }, - signal: "$waitlistSignal", - }, - { - matcher: (path) => path === "/waitlist/join", - signal: "$waitlistSignal", - }, - ], - } satisfies BetterAuthClientPlugin; -}; + return { + id: 'waitlist', + $InferServerPlugin: {} as ReturnType, + getActions: $fetch => ({ + waitlist: { + join: async ( + data: { + email: string + referredBy?: string + metadata?: Record + }, + fetchOptions?: RequestInit, + ) => { + return $fetch('/waitlist/join', { + method: 'POST', + body: data, + ...fetchOptions, + }) + }, + status: async ( + data: { token: string } | { query: { token: string } }, + fetchOptions?: RequestInit, + ) => { + const query = 'query' in data ? data.query : data + return $fetch('/waitlist/status', { + method: 'GET', + query, + ...fetchOptions, + }) + }, + verifyInvite: async ( + data: { inviteCode: string }, + fetchOptions?: RequestInit, + ) => { + return $fetch('/waitlist/verify-invite', { + method: 'POST', + body: data, + ...fetchOptions, + }) + }, + }, + $Infer: {} as { + WaitlistEntry: WaitlistEntry + }, + }), + getAtoms($fetch) { + const waitlistStats = useAuthQuery( + $waitlistSignal, + '/waitlist/stats', + $fetch, + { + method: 'GET', + }, + ) + return { + $waitlistSignal, + waitlistStats, + } + }, + pathMethods: { + '/waitlist/join': 'POST', + '/waitlist/status': 'GET', + '/waitlist/verify-invite': 'POST', + '/waitlist/approve': 'POST', + '/waitlist/reject': 'POST', + '/waitlist/bulk-approve': 'POST', + '/waitlist/list': 'GET', + '/waitlist/stats': 'GET', + }, + atomListeners: [ + { + matcher(path) { + return ( + path === '/waitlist/approve' + || path === '/waitlist/reject' + || path === '/waitlist/bulk-approve' + ) + }, + signal: '$waitlistSignal', + }, + { + matcher: path => path === '/waitlist/join', + signal: '$waitlistSignal', + }, + ], + } satisfies BetterAuthClientPlugin +} -export type { WaitlistClientOptions, WaitlistEntry } from "./types"; +export type { WaitlistClientOptions, WaitlistEntry } from './types' diff --git a/packages/waitlist/src/error-codes.ts b/packages/waitlist/src/error-codes.ts index 440ca2f..ec35d96 100644 --- a/packages/waitlist/src/error-codes.ts +++ b/packages/waitlist/src/error-codes.ts @@ -1,5 +1,5 @@ // NOTE: Error code const must be all capital of string (ref https://github.com/better-auth/better-auth/issues/4386) -import { defineErrorCodes } from "@better-auth/core/utils/error-codes"; +import { defineErrorCodes } from '@better-auth/core/utils/error-codes' /** * Error codes returned by the waitlist plugin endpoints. @@ -17,13 +17,13 @@ import { defineErrorCodes } from "@better-auth/core/utils/error-codes"; * ``` */ export const WAITLIST_ERROR_CODES = defineErrorCodes({ - EMAIL_ALREADY_IN_WAITLIST: "This email is already on the waitlist", - WAITLIST_ENTRY_NOT_FOUND: "Waitlist entry not found", - NOT_APPROVED: "You must be approved from the waitlist to register", - INVALID_INVITE_CODE: "Invalid or expired invite code", - INVITE_CODE_REQUIRED: "An invite code is required to register", - ALREADY_REGISTERED: - "This waitlist entry has already been used for registration", - WAITLIST_FULL: "The waitlist is currently full", - UNAUTHORIZED_ADMIN_ACTION: "You are not authorized to perform this action", -}); + EMAIL_ALREADY_IN_WAITLIST: 'This email is already on the waitlist', + WAITLIST_ENTRY_NOT_FOUND: 'Waitlist entry not found', + NOT_APPROVED: 'You must be approved from the waitlist to register', + INVALID_INVITE_CODE: 'Invalid or expired invite code', + INVITE_CODE_REQUIRED: 'An invite code is required to register', + ALREADY_REGISTERED: + 'This waitlist entry has already been used for registration', + WAITLIST_FULL: 'The waitlist is currently full', + UNAUTHORIZED_ADMIN_ACTION: 'You are not authorized to perform this action', +}) diff --git a/packages/waitlist/src/index.ts b/packages/waitlist/src/index.ts index dc22e0b..ec7d928 100644 --- a/packages/waitlist/src/index.ts +++ b/packages/waitlist/src/index.ts @@ -1,38 +1,38 @@ -import { createAuthMiddleware } from "@better-auth/core/api"; -import { APIError } from "@better-auth/core/error"; -import type { BetterAuthPlugin } from "better-auth"; -import { mergeSchema } from "better-auth/db"; -import { WAITLIST_ERROR_CODES } from "./error-codes"; +import type { BetterAuthPlugin } from 'better-auth' +import type { WaitlistOptions } from './types' +import { createAuthMiddleware } from '@better-auth/core/api' +import { APIError } from '@better-auth/core/error' +import { mergeSchema } from 'better-auth/db' +import { WAITLIST_ERROR_CODES } from './error-codes' import { - approveEntry, - bulkApprove, - getWaitlistStats, - listWaitlist, - rejectEntry, -} from "./routes/admin"; + approveEntry, + bulkApprove, + getWaitlistStats, + listWaitlist, + rejectEntry, +} from './routes/admin' import { - getWaitlistStatus, - joinWaitlist, - verifyInviteCode, -} from "./routes/public"; -import { schema } from "./schema"; -import type { WaitlistOptions } from "./types"; + getWaitlistStatus, + joinWaitlist, + verifyInviteCode, +} from './routes/public' +import { schema } from './schema' -export { WAITLIST_ERROR_CODES } from "./error-codes"; -export type { WaitlistOptions, WaitlistEntry, WaitlistStatus } from "./types"; +export { WAITLIST_ERROR_CODES } from './error-codes' +export type { WaitlistEntry, WaitlistOptions, WaitlistStatus } from './types' const DEFAULT_INTERCEPT_PATHS = [ - "/sign-up/email", - "/callback/", - "/oauth2/callback/", - "/magic-link/verify", - "/sign-in/email-otp", - "/email-otp/verify-email", - "/phone-number/verify", - "/sign-in/anonymous", - "/one-tap/callback", - "/siwe/verify", -]; + '/sign-up/email', + '/callback/', + '/oauth2/callback/', + '/magic-link/verify', + '/sign-in/email-otp', + '/email-otp/verify-email', + '/phone-number/verify', + '/sign-in/anonymous', + '/one-tap/callback', + '/siwe/verify', +] /** * Waitlist plugin for Better Auth. @@ -62,206 +62,215 @@ const DEFAULT_INTERCEPT_PATHS = [ * }); * ``` */ -export const waitlist = (options?: WaitlistOptions) => { - const opts: WaitlistOptions = { - enabled: true, - requireInviteCode: false, - inviteCodeExpiration: 172800, - skipAnonymous: false, - adminRoles: ["admin"], - ...options, - }; +export function waitlist(options?: WaitlistOptions) { + const opts: WaitlistOptions = { + enabled: true, + requireInviteCode: false, + inviteCodeExpiration: 172800, + skipAnonymous: false, + adminRoles: ['admin'], + ...options, + } - return { - id: "waitlist", + return { + id: 'waitlist', - init() { - return { - options: { - databaseHooks: { - user: { - create: { - async before(user, ctx) { - if (opts.enabled === false) return; + init() { + return { + options: { + databaseHooks: { + user: { + create: { + async before(user, ctx) { + if (opts.enabled === false) + return - // Skip anonymous users if configured - if ( - opts.skipAnonymous && - (user as Record).isAnonymous - ) - return; + // Skip anonymous users if configured + if ( + opts.skipAnonymous + && (user as Record).isAnonymous + ) { + return + } - // No email means we can't check waitlist - if (!user.email) return false; + // No email means we can't check waitlist + if (!user.email) + return false - const email = user.email.toLowerCase(); + const email = user.email.toLowerCase() - if (!ctx) return false; - const adapter = ctx.context?.adapter; - if (!adapter) return false; + if (!ctx) + return false + const adapter = ctx.context?.adapter + if (!adapter) + return false - const entry = await adapter.findOne({ - model: "waitlist", - where: [ - { field: "email", value: email }, - { field: "status", value: "approved" }, - ], - }); + const entry = await adapter.findOne({ + model: 'waitlist', + where: [ + { field: 'email', value: email }, + { field: 'status', value: 'approved' }, + ], + }) - // If no approved entry, block user creation - if (!entry) { - return false; - } - }, - async after(user, ctx) { - if (opts.enabled === false) return; - if (!user.email) return; - if (!ctx) { - console.error('[waitlist] after hook: ctx is missing, cannot mark user as registered'); - return; - } + // If no approved entry, block user creation + if (!entry) { + return false + } + }, + async after(user, ctx) { + if (opts.enabled === false) + return + if (!user.email) + return + if (!ctx) { + console.error('[waitlist] after hook: ctx is missing, cannot mark user as registered') + return + } - const email = user.email.toLowerCase(); - const adapter = ctx.context?.adapter; - if (!adapter) { - console.error('[waitlist] after hook: adapter is missing, cannot mark user as registered'); - return; - } + const email = user.email.toLowerCase() + const adapter = ctx.context?.adapter + if (!adapter) { + console.error('[waitlist] after hook: adapter is missing, cannot mark user as registered') + return + } - // Mark waitlist entry as registered - const entry = await adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: email }], - }); + // Mark waitlist entry as registered + const entry = await adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: email }], + }) - if (entry) { - await adapter.update({ - model: "waitlist", - where: [{ field: "email", value: email }], - update: { - status: "registered", - registeredAt: new Date(), - updatedAt: new Date(), - }, - }); - } - }, - }, - }, - }, - }, - }; - }, + if (entry) { + await adapter.update({ + model: 'waitlist', + where: [{ field: 'email', value: email }], + update: { + status: 'registered', + registeredAt: new Date(), + updatedAt: new Date(), + }, + }) + } + }, + }, + }, + }, + }, + } + }, - endpoints: { - joinWaitlist: joinWaitlist(opts), - getWaitlistStatus: getWaitlistStatus(opts), - verifyInviteCode: verifyInviteCode(opts), - approveEntry: approveEntry(opts), - rejectEntry: rejectEntry(opts), - bulkApprove: bulkApprove(opts), - listWaitlist: listWaitlist(opts), - getWaitlistStats: getWaitlistStats(opts), - }, + endpoints: { + joinWaitlist: joinWaitlist(opts), + getWaitlistStatus: getWaitlistStatus(opts), + verifyInviteCode: verifyInviteCode(opts), + approveEntry: approveEntry(opts), + rejectEntry: rejectEntry(opts), + bulkApprove: bulkApprove(opts), + listWaitlist: listWaitlist(opts), + getWaitlistStats: getWaitlistStats(opts), + }, - hooks: { - before: [ - { - matcher(context: { path?: string }) { - if (opts.enabled === false) return false; - const paths = opts.interceptPaths ?? DEFAULT_INTERCEPT_PATHS; - return paths.some( - (p) => context.path === p || context.path?.startsWith(p), - ); - }, - handler: createAuthMiddleware(async (ctx) => { - // Extract email from request body - const email = ctx.body?.email as string | undefined; + hooks: { + before: [ + { + matcher(context: { path?: string }) { + if (opts.enabled === false) + return false + const paths = opts.interceptPaths ?? DEFAULT_INTERCEPT_PATHS + return paths.some( + p => context.path === p || context.path?.startsWith(p), + ) + }, + handler: createAuthMiddleware(async (ctx) => { + // Extract email from request body + const email = ctx.body?.email as string | undefined - if (email) { - const normalizedEmail = email.toLowerCase(); + if (email) { + const normalizedEmail = email.toLowerCase() - // Check if this is a login (user already exists) vs signup - const existingUser = - await ctx.context.internalAdapter.findUserByEmail( - normalizedEmail, - ); - if (existingUser) { - // Existing user -- this is a login, let it through - return; - } - } + // Check if this is a login (user already exists) vs signup + const existingUser + = await ctx.context.internalAdapter.findUserByEmail( + normalizedEmail, + ) + if (existingUser) { + // Existing user -- this is a login, let it through + return + } + } - if (opts.requireInviteCode && email) { - // Require invite code in body or header - // Skip for OAuth callbacks (no email in body) — databaseHooks will catch it - const code = - (ctx.body?.inviteCode as string | undefined) || - ctx.headers?.get("x-invite-code"); - if (!code) { - throw APIError.from( - "FORBIDDEN", - WAITLIST_ERROR_CODES.INVITE_CODE_REQUIRED, - ); - } + if (opts.requireInviteCode && email) { + // Require invite code in body or header + // Skip for OAuth callbacks (no email in body) — databaseHooks will catch it + const code + = (ctx.body?.inviteCode as string | undefined) + || ctx.headers?.get('x-invite-code') + if (!code) { + throw APIError.from( + 'FORBIDDEN', + WAITLIST_ERROR_CODES.INVITE_CODE_REQUIRED, + ) + } - const normalizedEmail = email.toLowerCase(); - const entry = (await ctx.context.adapter.findOne({ - model: "waitlist", - where: [ - { field: "inviteCode", value: code }, - { field: "status", value: "approved" }, - ], - })) as Record | null; + const normalizedEmail = email.toLowerCase() + const entry = (await ctx.context.adapter.findOne({ + model: 'waitlist', + where: [ + { field: 'inviteCode', value: code }, + { field: 'status', value: 'approved' }, + ], + })) as Record | null - if (!entry) { - throw APIError.from( - "FORBIDDEN", - WAITLIST_ERROR_CODES.INVALID_INVITE_CODE, - ); - } + if (!entry) { + throw APIError.from( + 'FORBIDDEN', + WAITLIST_ERROR_CODES.INVALID_INVITE_CODE, + ) + } - // Verify invite code belongs to the registering email - if (entry.email !== normalizedEmail) { - throw APIError.from( - "FORBIDDEN", - WAITLIST_ERROR_CODES.INVALID_INVITE_CODE, - ); - } + // Verify invite code belongs to the registering email + if (entry.email !== normalizedEmail) { + throw APIError.from( + 'FORBIDDEN', + WAITLIST_ERROR_CODES.INVALID_INVITE_CODE, + ) + } - // Check expiration - if ( - entry.inviteExpiresAt && - new Date(entry.inviteExpiresAt as string) < new Date() - ) { - throw APIError.from( - "FORBIDDEN", - WAITLIST_ERROR_CODES.INVALID_INVITE_CODE, - ); - } - } else if (email) { - // No invite code required -- just check approval status - const normalizedEmail = email.toLowerCase(); - const entry = (await ctx.context.adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: normalizedEmail }], - })) as Record | null; + // Check expiration + if ( + entry.inviteExpiresAt + && new Date(entry.inviteExpiresAt as string) < new Date() + ) { + throw APIError.from( + 'FORBIDDEN', + WAITLIST_ERROR_CODES.INVALID_INVITE_CODE, + ) + } + } + else if (email) { + // No invite code required -- just check approval status + const normalizedEmail = email.toLowerCase() + const entry = (await ctx.context.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: normalizedEmail }], + })) as Record | null - if (!entry || entry.status !== "approved") { - throw APIError.from( - "FORBIDDEN", - WAITLIST_ERROR_CODES.NOT_APPROVED, - ); - } - } - // If no email in body (e.g., OAuth callbacks), the databaseHooks - // will catch it - }), - }, - ], - }, + if (!entry || entry.status !== 'approved') { + throw APIError.from( + 'FORBIDDEN', + WAITLIST_ERROR_CODES.NOT_APPROVED, + ) + } + } + // If no email in body (e.g., OAuth callbacks), the databaseHooks + // will catch it + }), + }, + ], + }, - schema: mergeSchema(schema, opts.schema), - $ERROR_CODES: WAITLIST_ERROR_CODES, - } satisfies BetterAuthPlugin; -}; + schema: mergeSchema(schema, opts.schema), + $ERROR_CODES: WAITLIST_ERROR_CODES, + } satisfies BetterAuthPlugin +} diff --git a/packages/waitlist/src/routes/admin.ts b/packages/waitlist/src/routes/admin.ts index 7733eb0..cd503e3 100644 --- a/packages/waitlist/src/routes/admin.ts +++ b/packages/waitlist/src/routes/admin.ts @@ -1,368 +1,376 @@ +import type { WaitlistEntry, WaitlistOptions } from '../types' import { - createAuthEndpoint, - createAuthMiddleware, -} from "@better-auth/core/api"; -import { APIError } from "@better-auth/core/error"; -import { sessionMiddleware } from "better-auth/api"; -import * as z from "zod"; -import { WAITLIST_ERROR_CODES } from "../error-codes"; -import type { WaitlistEntry, WaitlistOptions } from "../types"; + createAuthEndpoint, + createAuthMiddleware, +} from '@better-auth/core/api' +import { APIError } from '@better-auth/core/error' +import { sessionMiddleware } from 'better-auth/api' +import * as z from 'zod' +import { WAITLIST_ERROR_CODES } from '../error-codes' -const adminMiddleware = (options: WaitlistOptions) => - createAuthMiddleware( - { - use: [sessionMiddleware], - }, - async (ctx) => { - const adminRoles = options.adminRoles ?? ["admin"]; - const userRole = (ctx.context.session.user as Record) - .role as string | undefined; - if (!userRole || !adminRoles.includes(userRole)) { - throw APIError.from( - "FORBIDDEN", - WAITLIST_ERROR_CODES.UNAUTHORIZED_ADMIN_ACTION, - ); - } - return { - session: ctx.context.session, - }; - }, - ); +function adminMiddleware(options: WaitlistOptions) { + return createAuthMiddleware( + { + use: [sessionMiddleware], + }, + async (ctx) => { + const adminRoles = options.adminRoles ?? ['admin'] + const userRole = (ctx.context.session.user as Record) + .role as string | undefined + if (!userRole || !adminRoles.includes(userRole)) { + throw APIError.from( + 'FORBIDDEN', + WAITLIST_ERROR_CODES.UNAUTHORIZED_ADMIN_ACTION, + ) + } + return { + session: ctx.context.session, + } + }, + ) +} -export const approveEntry = (options: WaitlistOptions) => - createAuthEndpoint( - "/waitlist/approve", - { - method: "POST", - body: z.object({ - email: z.email(), - }), - use: [adminMiddleware(options)], - metadata: { - openapi: { - description: "Approve a waitlist entry", - responses: { 200: { description: "Entry approved" } }, - }, - }, - }, - async (ctx) => { - const normalizedEmail = ctx.body.email.toLowerCase(); - const entry = (await ctx.context.adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: normalizedEmail }], - })) as Record | null; - if (!entry) { - throw APIError.from( - "NOT_FOUND", - WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, - ); - } - if (entry.status !== "pending") { - throw APIError.from( - "BAD_REQUEST", - WAITLIST_ERROR_CODES.ALREADY_REGISTERED, - ); - } +export function approveEntry(options: WaitlistOptions) { + return createAuthEndpoint( + '/waitlist/approve', + { + method: 'POST', + body: z.object({ + email: z.email(), + }), + use: [adminMiddleware(options)], + metadata: { + openapi: { + description: 'Approve a waitlist entry', + responses: { 200: { description: 'Entry approved' } }, + }, + }, + }, + async (ctx) => { + const normalizedEmail = ctx.body.email.toLowerCase() + const entry = (await ctx.context.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: normalizedEmail }], + })) as Record | null + if (!entry) { + throw APIError.from( + 'NOT_FOUND', + WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, + ) + } + if (entry.status !== 'pending') { + throw APIError.from( + 'BAD_REQUEST', + WAITLIST_ERROR_CODES.ALREADY_REGISTERED, + ) + } - const inviteCode = crypto.randomUUID(); - const expSeconds = options.inviteCodeExpiration ?? 172800; - const inviteExpiresAt = new Date(Date.now() + expSeconds * 1000); - const now = new Date(); + const inviteCode = crypto.randomUUID() + const expSeconds = options.inviteCodeExpiration ?? 172800 + const inviteExpiresAt = new Date(Date.now() + expSeconds * 1000) + const now = new Date() - const updated = (await ctx.context.adapter.update({ - model: "waitlist", - where: [{ field: "email", value: normalizedEmail }], - update: { - status: "approved", - inviteCode, - inviteExpiresAt, - approvedAt: now, - updatedAt: now, - }, - })) as Record; + const updated = (await ctx.context.adapter.update({ + model: 'waitlist', + where: [{ field: 'email', value: normalizedEmail }], + update: { + status: 'approved', + inviteCode, + inviteExpiresAt, + approvedAt: now, + updatedAt: now, + }, + })) as Record - const updatedEntry = { ...entry, ...updated } as unknown as WaitlistEntry; + const updatedEntry = { ...entry, ...updated } as unknown as WaitlistEntry - if (options.sendInviteEmail) { - await options.sendInviteEmail({ - email: normalizedEmail, - inviteCode, - expiresAt: inviteExpiresAt, - }); - } - if (options.onApproved) { - await options.onApproved(updatedEntry); - } + if (options.sendInviteEmail) { + await options.sendInviteEmail({ + email: normalizedEmail, + inviteCode, + expiresAt: inviteExpiresAt, + }) + } + if (options.onApproved) { + await options.onApproved(updatedEntry) + } - return ctx.json(updatedEntry); - }, - ); + return ctx.json(updatedEntry) + }, + ) +} -export const rejectEntry = (options: WaitlistOptions) => - createAuthEndpoint( - "/waitlist/reject", - { - method: "POST", - body: z.object({ - email: z.email(), - }), - use: [adminMiddleware(options)], - metadata: { - openapi: { - description: "Reject a waitlist entry", - responses: { 200: { description: "Entry rejected" } }, - }, - }, - }, - async (ctx) => { - const normalizedEmail = ctx.body.email.toLowerCase(); - const entry = (await ctx.context.adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: normalizedEmail }], - })) as Record | null; - if (!entry) { - throw APIError.from( - "NOT_FOUND", - WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, - ); - } +export function rejectEntry(options: WaitlistOptions) { + return createAuthEndpoint( + '/waitlist/reject', + { + method: 'POST', + body: z.object({ + email: z.email(), + }), + use: [adminMiddleware(options)], + metadata: { + openapi: { + description: 'Reject a waitlist entry', + responses: { 200: { description: 'Entry rejected' } }, + }, + }, + }, + async (ctx) => { + const normalizedEmail = ctx.body.email.toLowerCase() + const entry = (await ctx.context.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: normalizedEmail }], + })) as Record | null + if (!entry) { + throw APIError.from( + 'NOT_FOUND', + WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, + ) + } - const now = new Date(); - const updated = (await ctx.context.adapter.update({ - model: "waitlist", - where: [{ field: "email", value: normalizedEmail }], - update: { - status: "rejected", - rejectedAt: now, - updatedAt: now, - }, - })) as Record; + const now = new Date() + const updated = (await ctx.context.adapter.update({ + model: 'waitlist', + where: [{ field: 'email', value: normalizedEmail }], + update: { + status: 'rejected', + rejectedAt: now, + updatedAt: now, + }, + })) as Record - const updatedEntry = { ...entry, ...updated } as unknown as WaitlistEntry; - if (options.onRejected) { - await options.onRejected(updatedEntry); - } + const updatedEntry = { ...entry, ...updated } as unknown as WaitlistEntry + if (options.onRejected) { + await options.onRejected(updatedEntry) + } - return ctx.json(updatedEntry); - }, - ); + return ctx.json(updatedEntry) + }, + ) +} -export const bulkApprove = (options: WaitlistOptions) => - createAuthEndpoint( - "/waitlist/bulk-approve", - { - method: "POST", - body: z.object({ - emails: z.array(z.email()).optional(), - count: z.number().int().positive().optional(), - }), - use: [adminMiddleware(options)], - metadata: { - openapi: { - description: "Bulk approve waitlist entries", - responses: { 200: { description: "Entries approved" } }, - }, - }, - }, - async (ctx) => { - const { emails, count } = ctx.body; - const approved: WaitlistEntry[] = []; - const inviteExpSeconds = options.inviteCodeExpiration ?? 172800; +export function bulkApprove(options: WaitlistOptions) { + return createAuthEndpoint( + '/waitlist/bulk-approve', + { + method: 'POST', + body: z.object({ + emails: z.array(z.email()).optional(), + count: z.number().int().positive().optional(), + }), + use: [adminMiddleware(options)], + metadata: { + openapi: { + description: 'Bulk approve waitlist entries', + responses: { 200: { description: 'Entries approved' } }, + }, + }, + }, + async (ctx) => { + const { emails, count } = ctx.body + const approved: WaitlistEntry[] = [] + const inviteExpSeconds = options.inviteCodeExpiration ?? 172800 - if (emails && emails.length > 0) { - for (const email of emails) { - const normalizedEmail = email.toLowerCase(); - const entry = (await ctx.context.adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: normalizedEmail }], - })) as Record | null; - if (!entry || entry.status !== "pending") continue; + if (emails && emails.length > 0) { + for (const email of emails) { + const normalizedEmail = email.toLowerCase() + const entry = (await ctx.context.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: normalizedEmail }], + })) as Record | null + if (!entry || entry.status !== 'pending') + continue - const inviteCode = crypto.randomUUID(); - const inviteExpiresAt = new Date( - Date.now() + inviteExpSeconds * 1000, - ); - const now = new Date(); + const inviteCode = crypto.randomUUID() + const inviteExpiresAt = new Date( + Date.now() + inviteExpSeconds * 1000, + ) + const now = new Date() - await ctx.context.adapter.update({ - model: "waitlist", - where: [{ field: "email", value: normalizedEmail }], - update: { - status: "approved", - inviteCode, - inviteExpiresAt, - approvedAt: now, - updatedAt: now, - }, - }); + await ctx.context.adapter.update({ + model: 'waitlist', + where: [{ field: 'email', value: normalizedEmail }], + update: { + status: 'approved', + inviteCode, + inviteExpiresAt, + approvedAt: now, + updatedAt: now, + }, + }) - const updatedEntry = { - ...entry, - status: "approved", - inviteCode, - inviteExpiresAt, - approvedAt: now, - updatedAt: now, - } as unknown as WaitlistEntry; - approved.push(updatedEntry); + const updatedEntry = { + ...entry, + status: 'approved', + inviteCode, + inviteExpiresAt, + approvedAt: now, + updatedAt: now, + } as unknown as WaitlistEntry + approved.push(updatedEntry) - if (options.sendInviteEmail) { - await options.sendInviteEmail({ - email: normalizedEmail, - inviteCode, - expiresAt: inviteExpiresAt, - }); - } - if (options.onApproved) { - await options.onApproved(updatedEntry); - } - } - } else if (count) { - const pending = (await ctx.context.adapter.findMany({ - model: "waitlist", - where: [{ field: "status", value: "pending" }], - sortBy: { field: "createdAt", direction: "asc" }, - limit: count, - })) as Record[]; + if (options.sendInviteEmail) { + await options.sendInviteEmail({ + email: normalizedEmail, + inviteCode, + expiresAt: inviteExpiresAt, + }) + } + if (options.onApproved) { + await options.onApproved(updatedEntry) + } + } + } + else if (count) { + const pending = (await ctx.context.adapter.findMany({ + model: 'waitlist', + where: [{ field: 'status', value: 'pending' }], + sortBy: { field: 'createdAt', direction: 'asc' }, + limit: count, + })) as Record[] - for (const entry of pending) { - const inviteCode = crypto.randomUUID(); - const inviteExpiresAt = new Date( - Date.now() + inviteExpSeconds * 1000, - ); - const now = new Date(); + for (const entry of pending) { + const inviteCode = crypto.randomUUID() + const inviteExpiresAt = new Date( + Date.now() + inviteExpSeconds * 1000, + ) + const now = new Date() - await ctx.context.adapter.update({ - model: "waitlist", - where: [{ field: "id", value: entry.id as string }], - update: { - status: "approved", - inviteCode, - inviteExpiresAt, - approvedAt: now, - updatedAt: now, - }, - }); + await ctx.context.adapter.update({ + model: 'waitlist', + where: [{ field: 'id', value: entry.id as string }], + update: { + status: 'approved', + inviteCode, + inviteExpiresAt, + approvedAt: now, + updatedAt: now, + }, + }) - const updatedEntry = { - ...entry, - status: "approved", - inviteCode, - inviteExpiresAt, - approvedAt: now, - updatedAt: now, - } as unknown as WaitlistEntry; - approved.push(updatedEntry); + const updatedEntry = { + ...entry, + status: 'approved', + inviteCode, + inviteExpiresAt, + approvedAt: now, + updatedAt: now, + } as unknown as WaitlistEntry + approved.push(updatedEntry) - if (options.sendInviteEmail) { - await options.sendInviteEmail({ - email: entry.email as string, - inviteCode, - expiresAt: inviteExpiresAt, - }); - } - if (options.onApproved) { - await options.onApproved(updatedEntry); - } - } - } + if (options.sendInviteEmail) { + await options.sendInviteEmail({ + email: entry.email as string, + inviteCode, + expiresAt: inviteExpiresAt, + }) + } + if (options.onApproved) { + await options.onApproved(updatedEntry) + } + } + } - return ctx.json({ approved: approved.length, entries: approved }); - }, - ); + return ctx.json({ approved: approved.length, entries: approved }) + }, + ) +} -export const listWaitlist = (options: WaitlistOptions) => - createAuthEndpoint( - "/waitlist/list", - { - method: "GET", - query: z.object({ - status: z - .enum(["pending", "approved", "rejected", "registered"]) - .optional(), - page: z.coerce.number().int().positive().optional(), - limit: z.coerce.number().int().positive().max(100).optional(), - sortBy: z.enum(["createdAt", "email", "status"]).optional(), - sortDirection: z.enum(["asc", "desc"]).optional(), - }), - use: [adminMiddleware(options)], - metadata: { - openapi: { - description: "List waitlist entries with pagination", - responses: { - 200: { description: "Paginated waitlist entries" }, - }, - }, - }, - }, - async (ctx) => { - const page = ctx.query.page ?? 1; - const limit = ctx.query.limit ?? 20; - const offset = (page - 1) * limit; +export function listWaitlist(options: WaitlistOptions) { + return createAuthEndpoint( + '/waitlist/list', + { + method: 'GET', + query: z.object({ + status: z + .enum(['pending', 'approved', 'rejected', 'registered']) + .optional(), + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), + sortBy: z.enum(['createdAt', 'email', 'status']).optional(), + sortDirection: z.enum(['asc', 'desc']).optional(), + }), + use: [adminMiddleware(options)], + metadata: { + openapi: { + description: 'List waitlist entries with pagination', + responses: { + 200: { description: 'Paginated waitlist entries' }, + }, + }, + }, + }, + async (ctx) => { + const page = ctx.query.page ?? 1 + const limit = ctx.query.limit ?? 20 + const offset = (page - 1) * limit - const where = ctx.query.status - ? [{ field: "status" as const, value: ctx.query.status }] - : undefined; + const where = ctx.query.status + ? [{ field: 'status' as const, value: ctx.query.status }] + : undefined - const entries = await ctx.context.adapter.findMany({ - model: "waitlist", - where, - sortBy: { - field: ctx.query.sortBy ?? "createdAt", - direction: ctx.query.sortDirection ?? "desc", - }, - limit, - offset, - }); + const entries = await ctx.context.adapter.findMany({ + model: 'waitlist', + where, + sortBy: { + field: ctx.query.sortBy ?? 'createdAt', + direction: ctx.query.sortDirection ?? 'desc', + }, + limit, + offset, + }) - const total = await ctx.context.adapter.count({ - model: "waitlist", - where, - }); + const total = await ctx.context.adapter.count({ + model: 'waitlist', + where, + }) - return ctx.json({ - entries, - total, - page, - totalPages: Math.ceil(total / limit), - }); - }, - ); + return ctx.json({ + entries, + total, + page, + totalPages: Math.ceil(total / limit), + }) + }, + ) +} -export const getWaitlistStats = (options: WaitlistOptions) => - createAuthEndpoint( - "/waitlist/stats", - { - method: "GET", - use: [adminMiddleware(options)], - metadata: { - openapi: { - description: "Get waitlist statistics", - responses: { 200: { description: "Waitlist statistics" } }, - }, - }, - }, - async (ctx) => { - const total = await ctx.context.adapter.count({ - model: "waitlist", - }); - const pending = await ctx.context.adapter.count({ - model: "waitlist", - where: [{ field: "status", value: "pending" }], - }); - const approved = await ctx.context.adapter.count({ - model: "waitlist", - where: [{ field: "status", value: "approved" }], - }); - const rejected = await ctx.context.adapter.count({ - model: "waitlist", - where: [{ field: "status", value: "rejected" }], - }); - const registered = await ctx.context.adapter.count({ - model: "waitlist", - where: [{ field: "status", value: "registered" }], - }); +export function getWaitlistStats(options: WaitlistOptions) { + return createAuthEndpoint( + '/waitlist/stats', + { + method: 'GET', + use: [adminMiddleware(options)], + metadata: { + openapi: { + description: 'Get waitlist statistics', + responses: { 200: { description: 'Waitlist statistics' } }, + }, + }, + }, + async (ctx) => { + const total = await ctx.context.adapter.count({ + model: 'waitlist', + }) + const pending = await ctx.context.adapter.count({ + model: 'waitlist', + where: [{ field: 'status', value: 'pending' }], + }) + const approved = await ctx.context.adapter.count({ + model: 'waitlist', + where: [{ field: 'status', value: 'approved' }], + }) + const rejected = await ctx.context.adapter.count({ + model: 'waitlist', + where: [{ field: 'status', value: 'rejected' }], + }) + const registered = await ctx.context.adapter.count({ + model: 'waitlist', + where: [{ field: 'status', value: 'registered' }], + }) - return ctx.json({ total, pending, approved, rejected, registered }); - }, - ); + return ctx.json({ total, pending, approved, rejected, registered }) + }, + ) +} diff --git a/packages/waitlist/src/routes/public.ts b/packages/waitlist/src/routes/public.ts index 646b456..4ffbb69 100644 --- a/packages/waitlist/src/routes/public.ts +++ b/packages/waitlist/src/routes/public.ts @@ -1,196 +1,199 @@ -import { createAuthEndpoint } from "@better-auth/core/api"; -import { APIError } from "@better-auth/core/error"; -import * as z from "zod"; -import { WAITLIST_ERROR_CODES } from "../error-codes"; -import type { WaitlistEntry, WaitlistOptions } from "../types"; +import type { WaitlistEntry, WaitlistOptions } from '../types' +import { createAuthEndpoint } from '@better-auth/core/api' +import { APIError } from '@better-auth/core/error' +import * as z from 'zod' +import { WAITLIST_ERROR_CODES } from '../error-codes' -export const joinWaitlist = (options: WaitlistOptions) => - createAuthEndpoint( - "/waitlist/join", - { - method: "POST", - body: z.object({ - email: z.email(), - referredBy: z.string().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - }), - metadata: { - openapi: { - description: "Join the waitlist", - responses: { - 200: { description: "Successfully joined the waitlist" }, - }, - }, - }, - }, - async (ctx) => { - const { email, referredBy, metadata } = ctx.body; - const normalizedEmail = email.toLowerCase(); +export function joinWaitlist(options: WaitlistOptions) { + return createAuthEndpoint( + '/waitlist/join', + { + method: 'POST', + body: z.object({ + email: z.email(), + referredBy: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }), + metadata: { + openapi: { + description: 'Join the waitlist', + responses: { + 200: { description: 'Successfully joined the waitlist' }, + }, + }, + }, + }, + async (ctx) => { + const { email, referredBy, metadata } = ctx.body + const normalizedEmail = email.toLowerCase() - // Check for duplicate - const existing = await ctx.context.adapter.findOne({ - model: "waitlist", - where: [{ field: "email", value: normalizedEmail }], - }); - if (existing) { - throw APIError.from( - "BAD_REQUEST", - WAITLIST_ERROR_CODES.EMAIL_ALREADY_IN_WAITLIST, - ); - } + // Check for duplicate + const existing = await ctx.context.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'email', value: normalizedEmail }], + }) + if (existing) { + throw APIError.from( + 'BAD_REQUEST', + WAITLIST_ERROR_CODES.EMAIL_ALREADY_IN_WAITLIST, + ) + } - // Check max size - if (options.maxWaitlistSize != null) { - const count = await ctx.context.adapter.count({ - model: "waitlist", - }); - if (count >= options.maxWaitlistSize) { - throw APIError.from( - "BAD_REQUEST", - WAITLIST_ERROR_CODES.WAITLIST_FULL, - ); - } - } + // Check max size + if (options.maxWaitlistSize != null) { + const count = await ctx.context.adapter.count({ + model: 'waitlist', + }) + if (count >= options.maxWaitlistSize) { + throw APIError.from( + 'BAD_REQUEST', + WAITLIST_ERROR_CODES.WAITLIST_FULL, + ) + } + } - // Check auto-approve - let status = "pending"; - let inviteCode: string | null = null; - let inviteExpiresAt: Date | null = null; - let approvedAt: Date | null = null; + // Check auto-approve + let status = 'pending' + let inviteCode: string | null = null + let inviteExpiresAt: Date | null = null + let approvedAt: Date | null = null - if (options.autoApprove) { - const shouldApprove = - typeof options.autoApprove === "function" - ? await options.autoApprove(normalizedEmail) - : true; - if (shouldApprove) { - status = "approved"; - inviteCode = crypto.randomUUID(); - const expSeconds = options.inviteCodeExpiration ?? 172800; - inviteExpiresAt = new Date(Date.now() + expSeconds * 1000); - approvedAt = new Date(); - } - } + if (options.autoApprove) { + const shouldApprove + = typeof options.autoApprove === 'function' + ? await options.autoApprove(normalizedEmail) + : true + if (shouldApprove) { + status = 'approved' + inviteCode = crypto.randomUUID() + const expSeconds = options.inviteCodeExpiration ?? 172800 + inviteExpiresAt = new Date(Date.now() + expSeconds * 1000) + approvedAt = new Date() + } + } - const lookupToken = crypto.randomUUID(); - const now = new Date(); - const entry = (await ctx.context.adapter.create({ - model: "waitlist", - data: { - email: normalizedEmail, - status, - lookupToken, - inviteCode, - inviteExpiresAt, - referredBy: referredBy ?? null, - metadata: metadata ? JSON.stringify(metadata) : null, - approvedAt, - rejectedAt: null, - registeredAt: null, - createdAt: now, - updatedAt: now, - }, - })) as Record; + const lookupToken = crypto.randomUUID() + const now = new Date() + const entry = (await ctx.context.adapter.create({ + model: 'waitlist', + data: { + email: normalizedEmail, + status, + lookupToken, + inviteCode, + inviteExpiresAt, + referredBy: referredBy ?? null, + metadata: metadata ? JSON.stringify(metadata) : null, + approvedAt, + rejectedAt: null, + registeredAt: null, + createdAt: now, + updatedAt: now, + }, + })) as Record - // Callbacks - if (options.onJoinWaitlist) { - await options.onJoinWaitlist(entry as unknown as WaitlistEntry); - } - if ( - status === "approved" && - options.sendInviteEmail && - inviteCode && - inviteExpiresAt - ) { - await options.sendInviteEmail({ - email: normalizedEmail, - inviteCode, - expiresAt: inviteExpiresAt, - }); - } - if (status === "approved" && options.onApproved) { - await options.onApproved(entry as unknown as WaitlistEntry); - } + // Callbacks + if (options.onJoinWaitlist) { + await options.onJoinWaitlist(entry as unknown as WaitlistEntry) + } + if ( + status === 'approved' + && options.sendInviteEmail + && inviteCode + && inviteExpiresAt + ) { + await options.sendInviteEmail({ + email: normalizedEmail, + inviteCode, + expiresAt: inviteExpiresAt, + }) + } + if (status === 'approved' && options.onApproved) { + await options.onApproved(entry as unknown as WaitlistEntry) + } - return ctx.json({ - id: entry.id, - email: entry.email, - status: entry.status, - lookupToken, - createdAt: entry.createdAt, - }); - }, - ); + return ctx.json({ + id: entry.id, + email: entry.email, + status: entry.status, + lookupToken, + createdAt: entry.createdAt, + }) + }, + ) +} -export const getWaitlistStatus = (_options: WaitlistOptions) => - createAuthEndpoint( - "/waitlist/status", - { - method: "GET", - query: z.object({ - token: z.string(), - }), - metadata: { - openapi: { - description: "Check waitlist status using a lookup token", - responses: { - 200: { description: "Waitlist status" }, - }, - }, - }, - }, - async (ctx) => { - const entry = (await ctx.context.adapter.findOne({ - model: "waitlist", - where: [{ field: "lookupToken", value: ctx.query.token }], - })) as Record | null; - if (!entry) { - throw APIError.from( - "NOT_FOUND", - WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, - ); - } - return ctx.json({ - status: entry.status as string, - }); - }, - ); +export function getWaitlistStatus(_options: WaitlistOptions) { + return createAuthEndpoint( + '/waitlist/status', + { + method: 'GET', + query: z.object({ + token: z.string(), + }), + metadata: { + openapi: { + description: 'Check waitlist status using a lookup token', + responses: { + 200: { description: 'Waitlist status' }, + }, + }, + }, + }, + async (ctx) => { + const entry = (await ctx.context.adapter.findOne({ + model: 'waitlist', + where: [{ field: 'lookupToken', value: ctx.query.token }], + })) as Record | null + if (!entry) { + throw APIError.from( + 'NOT_FOUND', + WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, + ) + } + return ctx.json({ + status: entry.status as string, + }) + }, + ) +} -export const verifyInviteCode = (_options: WaitlistOptions) => - createAuthEndpoint( - "/waitlist/verify-invite", - { - method: "POST", - body: z.object({ - inviteCode: z.string(), - }), - metadata: { - openapi: { - description: "Verify a waitlist invite code", - responses: { - 200: { description: "Invite code verification result" }, - }, - }, - }, - }, - async (ctx) => { - const { inviteCode } = ctx.body; - const entry = (await ctx.context.adapter.findOne({ - model: "waitlist", - where: [ - { field: "inviteCode", value: inviteCode }, - { field: "status", value: "approved" }, - ], - })) as Record | null; - if (!entry) { - return ctx.json({ valid: false, email: null }); - } - if ( - entry.inviteExpiresAt && - new Date(entry.inviteExpiresAt as string) < new Date() - ) { - return ctx.json({ valid: false, email: null }); - } - return ctx.json({ valid: true, email: entry.email as string }); - }, - ); +export function verifyInviteCode(_options: WaitlistOptions) { + return createAuthEndpoint( + '/waitlist/verify-invite', + { + method: 'POST', + body: z.object({ + inviteCode: z.string(), + }), + metadata: { + openapi: { + description: 'Verify a waitlist invite code', + responses: { + 200: { description: 'Invite code verification result' }, + }, + }, + }, + }, + async (ctx) => { + const { inviteCode } = ctx.body + const entry = (await ctx.context.adapter.findOne({ + model: 'waitlist', + where: [ + { field: 'inviteCode', value: inviteCode }, + { field: 'status', value: 'approved' }, + ], + })) as Record | null + if (!entry) { + return ctx.json({ valid: false, email: null }) + } + if ( + entry.inviteExpiresAt + && new Date(entry.inviteExpiresAt as string) < new Date() + ) { + return ctx.json({ valid: false, email: null }) + } + return ctx.json({ valid: true, email: entry.email as string }) + }, + ) +} diff --git a/packages/waitlist/src/schema.ts b/packages/waitlist/src/schema.ts index 8ac7fc6..23f11eb 100644 --- a/packages/waitlist/src/schema.ts +++ b/packages/waitlist/src/schema.ts @@ -1,60 +1,60 @@ -import type { BetterAuthPluginDBSchema } from "@better-auth/core/db"; +import type { BetterAuthPluginDBSchema } from '@better-auth/core/db' export const schema = { - waitlist: { - fields: { - email: { - type: "string", - required: true, - unique: true, - }, - status: { - type: "string", - required: true, - defaultValue: "pending", - }, - lookupToken: { - type: "string", - required: true, - unique: true, - }, - inviteCode: { - type: "string", - required: false, - unique: true, - }, - inviteExpiresAt: { - type: "date", - required: false, - }, - referredBy: { - type: "string", - required: false, - }, - metadata: { - type: "string", - required: false, - }, - approvedAt: { - type: "date", - required: false, - }, - rejectedAt: { - type: "date", - required: false, - }, - registeredAt: { - type: "date", - required: false, - }, - createdAt: { - type: "date", - required: true, - }, - updatedAt: { - type: "date", - required: true, - }, - }, - }, -} satisfies BetterAuthPluginDBSchema; + waitlist: { + fields: { + email: { + type: 'string', + required: true, + unique: true, + }, + status: { + type: 'string', + required: true, + defaultValue: 'pending', + }, + lookupToken: { + type: 'string', + required: true, + unique: true, + }, + inviteCode: { + type: 'string', + required: false, + unique: true, + }, + inviteExpiresAt: { + type: 'date', + required: false, + }, + referredBy: { + type: 'string', + required: false, + }, + metadata: { + type: 'string', + required: false, + }, + approvedAt: { + type: 'date', + required: false, + }, + rejectedAt: { + type: 'date', + required: false, + }, + registeredAt: { + type: 'date', + required: false, + }, + createdAt: { + type: 'date', + required: true, + }, + updatedAt: { + type: 'date', + required: true, + }, + }, + }, +} satisfies BetterAuthPluginDBSchema diff --git a/packages/waitlist/src/types.ts b/packages/waitlist/src/types.ts index 076e1e7..20376ba 100644 --- a/packages/waitlist/src/types.ts +++ b/packages/waitlist/src/types.ts @@ -1,71 +1,71 @@ -export type WaitlistStatus = "pending" | "approved" | "rejected" | "registered"; +export type WaitlistStatus = 'pending' | 'approved' | 'rejected' | 'registered' export interface WaitlistEntry { - id: string; - email: string; - status: WaitlistStatus; - lookupToken: string; - inviteCode: string | null; - inviteExpiresAt: Date | null; - referredBy: string | null; - metadata: string | null; - approvedAt: Date | null; - rejectedAt: Date | null; - registeredAt: Date | null; - createdAt: Date; - updatedAt: Date; + id: string + email: string + status: WaitlistStatus + lookupToken: string + inviteCode: string | null + inviteExpiresAt: Date | null + referredBy: string | null + metadata: string | null + approvedAt: Date | null + rejectedAt: Date | null + registeredAt: Date | null + createdAt: Date + updatedAt: Date } export interface WaitlistOptions { - /** Whether the waitlist gate is active. Defaults to true. */ - enabled?: boolean; - /** Require an invite code to register instead of just being approved. */ - requireInviteCode?: boolean; - /** Invite code TTL in seconds. Defaults to 172800 (48 hours). */ - inviteCodeExpiration?: number; - /** Maximum number of entries allowed on the waitlist. */ - maxWaitlistSize?: number; - /** Skip waitlist checks for anonymous sign-ins. Defaults to false. */ - skipAnonymous?: boolean; - /** - * Automatically approve entries when they join. - * Pass `true` to auto-approve all, or a function for conditional logic. - */ - autoApprove?: boolean | ((email: string) => boolean | Promise); - /** - * List of Better Auth paths to intercept. Defaults to all registration paths. - */ - interceptPaths?: string[]; - /** - * Roles that are allowed to perform admin actions. - * Defaults to ["admin"]. - */ - adminRoles?: string[]; - /** Called after an entry joins the waitlist. */ - onJoinWaitlist?: (entry: WaitlistEntry) => void | Promise; - /** Called after an entry is approved. */ - onApproved?: (entry: WaitlistEntry) => void | Promise; - /** Called after an entry is rejected. */ - onRejected?: (entry: WaitlistEntry) => void | Promise; - /** - * Called when an entry is approved to send the invite email. - * You must implement this to deliver invite codes to users. - */ - sendInviteEmail?: (data: { - email: string; - inviteCode: string; - expiresAt: Date; - }) => void | Promise; - /** Customise table and field names for the waitlist schema. */ - schema?: { - waitlist?: { - modelName?: string; - fields?: Record; - }; - }; + /** Whether the waitlist gate is active. Defaults to true. */ + enabled?: boolean + /** Require an invite code to register instead of just being approved. */ + requireInviteCode?: boolean + /** Invite code TTL in seconds. Defaults to 172800 (48 hours). */ + inviteCodeExpiration?: number + /** Maximum number of entries allowed on the waitlist. */ + maxWaitlistSize?: number + /** Skip waitlist checks for anonymous sign-ins. Defaults to false. */ + skipAnonymous?: boolean + /** + * Automatically approve entries when they join. + * Pass `true` to auto-approve all, or a function for conditional logic. + */ + autoApprove?: boolean | ((email: string) => boolean | Promise) + /** + * List of Better Auth paths to intercept. Defaults to all registration paths. + */ + interceptPaths?: string[] + /** + * Roles that are allowed to perform admin actions. + * Defaults to ["admin"]. + */ + adminRoles?: string[] + /** Called after an entry joins the waitlist. */ + onJoinWaitlist?: (entry: WaitlistEntry) => void | Promise + /** Called after an entry is approved. */ + onApproved?: (entry: WaitlistEntry) => void | Promise + /** Called after an entry is rejected. */ + onRejected?: (entry: WaitlistEntry) => void | Promise + /** + * Called when an entry is approved to send the invite email. + * You must implement this to deliver invite codes to users. + */ + sendInviteEmail?: (data: { + email: string + inviteCode: string + expiresAt: Date + }) => void | Promise + /** Customise table and field names for the waitlist schema. */ + schema?: { + waitlist?: { + modelName?: string + fields?: Record + } + } } export interface WaitlistClientOptions { - /** Base URL override for waitlist API calls. */ - baseURL?: string; + /** Base URL override for waitlist API calls. */ + baseURL?: string } diff --git a/packages/waitlist/tsconfig.json b/packages/waitlist/tsconfig.json index 6b48db0..7bbaacb 100644 --- a/packages/waitlist/tsconfig.json +++ b/packages/waitlist/tsconfig.json @@ -1,24 +1,24 @@ { "compilerOptions": { - "strict": true, + "incremental": true, + "tsBuildInfoFile": "./node_modules/.cache/ts/tsbuildinfo", "target": "esnext", + "lib": ["esnext", "dom", "dom.iterable"], + "customConditions": [], "module": "esnext", "moduleResolution": "bundler", - "downlevelIteration": true, - "esModuleInterop": true, - "skipLibCheck": true, - "verbatimModuleSyntax": true, - "noUncheckedIndexedAccess": true, + "types": ["node", "bun"], + "strict": true, "exactOptionalPropertyTypes": false, - "incremental": true, + "noUncheckedIndexedAccess": true, "noErrorTruncation": true, "declaration": true, + "downlevelIteration": true, "emitDeclarationOnly": true, - "types": ["node", "bun"], - "lib": ["esnext", "dom", "dom.iterable"], "outDir": "./node_modules/.cache/ts/out", - "tsBuildInfoFile": "./node_modules/.cache/ts/tsbuildinfo", - "customConditions": [] + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true }, "include": ["./src"], "exclude": ["**/dist/**", "**/node_modules/**"] diff --git a/packages/waitlist/tsdown.config.ts b/packages/waitlist/tsdown.config.ts index 97122ea..7edb274 100644 --- a/packages/waitlist/tsdown.config.ts +++ b/packages/waitlist/tsdown.config.ts @@ -1,17 +1,17 @@ -import { defineConfig } from "tsdown"; +import { defineConfig } from 'tsdown' export default defineConfig({ - dts: { build: true, incremental: true }, - format: ["esm"], - entry: ["./src/index.ts", "./src/client.ts"], - external: [ - "nanostores", - "@better-auth/utils", - "better-call", - "@better-fetch/fetch", - "@better-auth/core", - "better-auth", - ], - sourcemap: true, - treeshake: true, -}); + dts: { build: true, incremental: true }, + format: ['esm'], + entry: ['./src/index.ts', './src/client.ts'], + external: [ + 'nanostores', + '@better-auth/utils', + 'better-call', + '@better-fetch/fetch', + '@better-auth/core', + 'better-auth', + ], + sourcemap: true, + treeshake: true, +}) diff --git a/packages/waitlist/vitest.config.ts b/packages/waitlist/vitest.config.ts index 5029772..3ceb503 100644 --- a/packages/waitlist/vitest.config.ts +++ b/packages/waitlist/vitest.config.ts @@ -1,11 +1,11 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config' export default defineConfig({ - test: { - clearMocks: true, - globals: true, - }, - resolve: { - conditions: ["dev-source"], - }, -}); + test: { + clearMocks: true, + globals: true, + }, + resolve: { + conditions: ['dev-source'], + }, +})