This is page 41 of 49. Use http://codebase.md/better-auth/better-auth?page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── sso │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sso.test.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── middleware │ │ │ │ └── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/tests/normal.ts: -------------------------------------------------------------------------------- ```typescript import { expect } from "vitest"; import { createTestSuite } from "../create-test-suite"; import type { User } from "../../types"; import type { BetterAuthPlugin } from "@better-auth/core"; /** * This test suite tests the basic CRUD operations of the adapter. */ export const normalTestSuite = createTestSuite("normal", {}, (helpers) => { const tests = getNormalTestSuiteTests(helpers); return { "init - tests": async () => { const opts = helpers.getBetterAuthOptions(); expect(opts.advanced?.database?.useNumberId).toBe(undefined); }, ...tests, }; }); export const getNormalTestSuiteTests = ({ adapter, generate, insertRandom, modifyBetterAuthOptions, sortModels, customIdGenerator, getBetterAuthOptions, }: Parameters<Parameters<typeof createTestSuite>[2]>[0]) => { return { "create - should create a model": async () => { const user = await generate("user"); const result = await adapter.create<User>({ model: "user", data: user, forceAllowId: true, }); const options = getBetterAuthOptions(); if (options.advanced?.database?.useNumberId) { expect(typeof result.id).toEqual("string"); user.id = result.id; } else { expect(typeof result.id).toEqual("string"); } expect(result).toEqual(user); }, "create - should always return an id": async () => { const { id: _, ...user } = await generate("user"); const res = await adapter.create<User>({ model: "user", data: user, }); expect(res).toHaveProperty("id"); expect(typeof res.id).toEqual("string"); }, "create - should use generateId if provided": async () => { const ID = (await customIdGenerator?.()) || "MOCK-ID"; await modifyBetterAuthOptions( { advanced: { database: { generateId: () => ID, }, }, }, false, ); const { id: _, ...user } = await generate("user"); const res = await adapter.create<User>({ model: "user", data: user, }); expect(res.id).toEqual(ID); const findResult = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: res.id }], }); expect(findResult).toEqual(res); }, "create - should return null for nullable foreign keys": async () => { await modifyBetterAuthOptions( { plugins: [ { id: "nullable-test", schema: { testModel: { fields: { nullableReference: { type: "string", references: { field: "id", model: "user" }, required: false, }, }, }, }, } satisfies BetterAuthPlugin, ], }, true, ); const { nullableReference } = await adapter.create<{ nullableReference: string | null; }>({ model: "testModel", data: { nullableReference: null }, forceAllowId: true, }); expect(nullableReference).toBeNull(); }, "findOne - should find a model": async () => { const [user] = await insertRandom("user"); const result = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: user.id }], }); expect(result).toEqual(user); }, "findOne - should find a model using a reference field": async () => { const [user, session] = await insertRandom("session"); const result = await adapter.findOne<User>({ model: "session", where: [{ field: "userId", value: user.id }], }); expect(result).toEqual(session); }, "findOne - should not throw on record not found": async () => { const result = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: "100000" }], }); expect(result).toBeNull(); }, "findOne - should find a model without id": async () => { const [user] = await insertRandom("user"); const result = await adapter.findOne<User>({ model: "user", where: [{ field: "email", value: user.email }], }); expect(result).toEqual(user); }, "findOne - should find a model with modified field name": async () => { await modifyBetterAuthOptions( { user: { fields: { email: "email_address", }, }, }, true, ); const [user] = await insertRandom("user"); const result = await adapter.findOne<User>({ model: "user", where: [{ field: "email", value: user.email }], }); expect(result).toEqual(user); expect(result?.email).toEqual(user.email); expect(true).toEqual(true); }, "findOne - should find a model with modified model name": async () => { await modifyBetterAuthOptions( { user: { modelName: "user_custom", }, }, true, ); const [user] = await insertRandom("user"); expect(user).toBeDefined(); expect(user).toHaveProperty("id"); expect(user).toHaveProperty("name"); const result = await adapter.findOne<User>({ model: "user", where: [{ field: "email", value: user.email }], }); expect(result).toEqual(user); expect(result?.email).toEqual(user.email); expect(true).toEqual(true); }, "findOne - should find a model with additional fields": async () => { await modifyBetterAuthOptions( { user: { additionalFields: { customField: { type: "string", input: false, required: true, defaultValue: "default-value", }, }, }, }, true, ); const [user_] = await insertRandom("user"); const user = user_ as User & { customField: string }; expect(user).toHaveProperty("customField"); expect(user.customField).toBe("default-value"); const result = await adapter.findOne<User & { customField: string }>({ model: "user", where: [{ field: "customField", value: user.customField }], }); expect(result).toEqual(user); expect(result?.customField).toEqual("default-value"); }, "findOne - should select fields": async () => { const [user] = await insertRandom("user"); const result = await adapter.findOne<Pick<User, "email" | "name">>({ model: "user", where: [{ field: "id", value: user.id }], select: ["email", "name"], }); expect(result).toEqual({ email: user.email, name: user.name }); }, "findOne - should find model with date field": async () => { const [user] = await insertRandom("user"); const result = await adapter.findOne<User>({ model: "user", where: [{ field: "createdAt", value: user.createdAt, operator: "eq" }], }); expect(result).toEqual(user); expect(result?.createdAt).toBeInstanceOf(Date); expect(result?.createdAt).toEqual(user.createdAt); }, "findMany - should find many models with date fields": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const youngestUser = users.sort( (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), )[0]!; const result = await adapter.findMany<User>({ model: "user", where: [ { field: "createdAt", value: youngestUser.createdAt, operator: "lt" }, ], }); expect(sortModels(result)).toEqual( sortModels( users.filter((user) => user.createdAt < youngestUser.createdAt), ), ); }, "findMany - should find many models": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", }); expect(sortModels(result)).toEqual(sortModels(users)); }, "findMany - should return an empty array when no models are found": async () => { const result = await adapter.findMany<User>({ model: "user", where: [{ field: "id", value: "100000" }], }); expect(result).toEqual([]); }, "findMany - should find many models with starts_with operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", where: [{ field: "name", value: "user", operator: "starts_with" }], }); expect(sortModels(result)).toEqual(sortModels(users)); }, "findMany - starts_with should not interpret regex patterns": async () => { // Create a user whose name literally starts with the regex-like prefix const userTemplate = await generate("user"); const literalRegexUser = await adapter.create<User>({ model: "user", data: { ...userTemplate, name: ".*danger", }, forceAllowId: true, }); // Also create some normal users that do NOT start with ".*" await insertRandom("user", 3); const result = await adapter.findMany<User>({ model: "user", where: [{ field: "name", value: ".*", operator: "starts_with" }], }); // Should only match the literal ".*" prefix, not treat it as a regex matching everything expect(result.length).toBe(1); expect(result[0]!.id).toBe(literalRegexUser.id); expect(result[0]!.name.startsWith(".*")).toBe(true); }, "findMany - ends_with should not interpret regex patterns": async () => { // Create a user whose name literally ends with the regex-like suffix const userTemplate = await generate("user"); const literalRegexUser = await adapter.create<User>({ model: "user", data: { ...userTemplate, name: "danger.*", }, forceAllowId: true, }); // Also create some normal users that do NOT end with ".*" await insertRandom("user", 3); const result = await adapter.findMany<User>({ model: "user", where: [{ field: "name", value: ".*", operator: "ends_with" }], }); // Should only match the literal ".*" suffix, not treat it as a regex matching everything expect(result.length).toBe(1); expect(result[0]!.id).toBe(literalRegexUser.id); expect(result[0]!.name.endsWith(".*")).toBe(true); }, "findMany - contains should not interpret regex patterns": async () => { // Create a user whose name literally contains the regex-like pattern const userTemplate = await generate("user"); const literalRegexUser = await adapter.create<User>({ model: "user", data: { ...userTemplate, name: "prefix-.*-suffix", }, forceAllowId: true, }); // Also create some normal users that do NOT contain ".*" await insertRandom("user", 3); const result = await adapter.findMany<User>({ model: "user", where: [{ field: "name", value: ".*", operator: "contains" }], }); // Should only match the literal substring ".*", not treat it as a regex matching everything expect(result.length).toBe(1); expect(result[0]!.id).toBe(literalRegexUser.id); expect(result[0]!.name.includes(".*")).toBe(true); }, "findMany - should find many models with ends_with operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); for (const user of users) { const res = await adapter.update<User>({ model: "user", where: [{ field: "id", value: user.id }], update: { name: user.name.toLowerCase() }, // make name lowercase }); if (!res) throw new Error("No result"); let u = users.find((u) => u.id === user.id)!; u.name = res.name; u.updatedAt = res.updatedAt; } const ends_with = users[0]!.name.slice(-1); const result = await adapter.findMany<User>({ model: "user", where: [ { field: "name", value: ends_with, operator: "ends_with", }, ], }); const expectedResult = sortModels( users.filter((user) => user.name.endsWith(ends_with)), ); if (result.length !== expectedResult.length) { console.log(`Result length: ${result.length}`); console.log(sortModels(result)); console.log("--------------------------------"); console.log( `Expected result length: ${expectedResult.length} - key: ${JSON.stringify(ends_with)}`, ); console.log(expectedResult); } expect(sortModels(result)).toEqual(expectedResult); }, "findMany - should find many models with contains operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); // if this check fails, the test will fail. // insertRandom needs to generate emails that contain `@email.com` expect(users[0]!.email).toContain("@email.com"); const result = await adapter.findMany<User>({ model: "user", where: [ { field: "email", value: "mail", // all emails contains `@email.com` from `insertRandom` operator: "contains", }, ], }); expect(sortModels(result)).toEqual(sortModels(users)); }, "findMany - should find many models with contains operator (using symbol)": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", where: [{ field: "email", value: "@", operator: "contains" }], }); expect(sortModels(result)).toEqual(sortModels(users)); }, "findMany - should find many models with eq operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", where: [{ field: "email", value: users[0]!.email, operator: "eq" }], }); expect(sortModels(result)).toEqual(sortModels([users[0]!])); }, "findMany - should find many models with ne operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", where: [{ field: "email", value: users[0]!.email, operator: "ne" }], }); expect(sortModels(result)).toEqual(sortModels(users.slice(1))); }, "findMany - should find many models with gt operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const oldestUser = users.sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), )[0]!; const result = await adapter.findMany<User>({ model: "user", where: [ { field: "createdAt", value: oldestUser.createdAt, operator: "gt", }, ], }); const expectedResult = sortModels( users.filter((user) => user.createdAt > oldestUser.createdAt), ); expect(result.length).not.toBe(0); expect(sortModels(result)).toEqual(expectedResult); }, "findMany - should find many models with gte operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const oldestUser = users.sort( (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), )[0]!; const result = await adapter.findMany<User>({ model: "user", where: [ { field: "createdAt", value: oldestUser.createdAt, operator: "gte", }, ], }); const expectedResult = users.filter( (user) => user.createdAt >= oldestUser.createdAt, ); expect(result.length).not.toBe(0); expect(sortModels(result)).toEqual(sortModels(expectedResult)); }, "findMany - should find many models with lte operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", where: [ { field: "createdAt", value: users[0]!.createdAt, operator: "lte" }, ], }); const expectedResult = users.filter( (user) => user.createdAt <= users[0]!.createdAt, ); expect(sortModels(result)).toEqual(sortModels(expectedResult)); }, "findMany - should find many models with lt operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", where: [ { field: "createdAt", value: users[0]!.createdAt, operator: "lt" }, ], }); const expectedResult = users.filter( (user) => user.createdAt < users[0]!.createdAt, ); expect(sortModels(result)).toEqual(sortModels(expectedResult)); }, "findMany - should find many models with in operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", where: [ { field: "id", value: [users[0]!.id, users[1]!.id], operator: "in", }, ], }); const expectedResult = users.filter( (user) => user.id === users[0]!.id || user.id === users[1]!.id, ); expect(sortModels(result)).toEqual(sortModels(expectedResult)); }, "findMany - should find many models with not_in operator": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", where: [ { field: "id", value: [users[0]!.id, users[1]!.id], operator: "not_in", }, ], }); expect(sortModels(result)).toEqual([users[2]]); }, "findMany - should find many models with sortBy": async () => { let n = -1; await modifyBetterAuthOptions( { user: { additionalFields: { numericField: { type: "number", defaultValue() { return n++; }, }, }, }, }, true, ); const users = (await insertRandom("user", 5)).map( (x) => x[0], ) as (User & { numericField: number })[]; const result = await adapter.findMany<User & { numericField: number }>({ model: "user", sortBy: { field: "numericField", direction: "asc" }, }); const expectedResult = users .map((x) => x.numericField) .sort((a, b) => a - b); try { expect(result.map((x) => x.numericField)).toEqual(expectedResult); } catch (error) { console.log(`--------------------------------`); console.log(`result:`); console.log(result.map((x) => x.id)); console.log(`expected result:`); console.log(expectedResult); console.log(`--------------------------------`); throw error; } const options = getBetterAuthOptions(); if (options.advanced?.database?.useNumberId) { expect(Number(users[0]!.id)).not.toBeNaN(); } }, "findMany - should find many models with limit": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); const result = await adapter.findMany<User>({ model: "user", limit: 1, }); expect(result.length).toEqual(1); expect(users.find((x) => x.id === result[0]!.id)).not.toBeNull(); }, "findMany - should find many models with offset": async () => { // Note: The returned rows are ordered in no particular order // This is because databases return rows in whatever order is fastest for the query. const count = 10; await insertRandom("user", count); const result = await adapter.findMany<User>({ model: "user", offset: 2, }); expect(result.length).toEqual(count - 2); }, "findMany - should find many models with limit and offset": async () => { // Note: The returned rows are ordered in no particular order // This is because databases return rows in whatever order is fastest for the query. const count = 5; await insertRandom("user", count); const result = await adapter.findMany<User>({ model: "user", limit: 2, offset: 2, }); expect(result.length).toEqual(2); expect(result).toBeInstanceOf(Array); result.forEach((user) => { expect(user).toHaveProperty("id"); expect(user).toHaveProperty("name"); expect(user).toHaveProperty("email"); }); }, "findMany - should find many models with sortBy and offset": async () => { let n = -1; await modifyBetterAuthOptions( { user: { additionalFields: { numericField: { type: "number", defaultValue() { return n++; }, }, }, }, }, true, ); const users = (await insertRandom("user", 5)).map( (x) => x[0], ) as (User & { numericField: number })[]; const result = await adapter.findMany<User>({ model: "user", sortBy: { field: "numericField", direction: "asc" }, offset: 2, }); expect(result).toHaveLength(3); expect(result).toEqual( users.sort((a, b) => a.numericField - b.numericField).slice(2), ); }, "findMany - should find many models with sortBy and limit": async () => { let n = -1; await modifyBetterAuthOptions( { user: { additionalFields: { numericField: { type: "number", defaultValue() { return n++; }, }, }, }, }, true, ); const users = (await insertRandom("user", 5)).map( (x) => x[0], ) as (User & { numericField: number })[]; const result = await adapter.findMany<User>({ model: "user", sortBy: { field: "numericField", direction: "asc" }, limit: 2, }); expect(result).toEqual( users.sort((a, b) => a.numericField - b.numericField).slice(0, 2), ); }, "findMany - should find many models with sortBy and limit and offset": async () => { let n = -1; await modifyBetterAuthOptions( { user: { additionalFields: { numericField: { type: "number", defaultValue() { return n++; }, }, }, }, }, true, ); const users = (await insertRandom("user", 5)).map( (x) => x[0], ) as (User & { numericField: number })[]; const result = await adapter.findMany<User>({ model: "user", sortBy: { field: "numericField", direction: "asc" }, limit: 2, offset: 2, }); expect(result.length).toBe(2); expect(result).toEqual( users.sort((a, b) => a.numericField - b.numericField).slice(2, 4), ); }, "findMany - should find many models with sortBy and limit and offset and where": async () => { let n = -1; await modifyBetterAuthOptions( { user: { additionalFields: { numericField: { type: "number", defaultValue() { return n++; }, }, }, }, }, true, ); let users = (await insertRandom("user", 10)).map( (x) => x[0], ) as (User & { numericField: number })[]; // update the last three users to end with "last" let i = -1; for (const user of users) { i++; if (i < 5) continue; const result = await adapter.update<User>({ model: "user", where: [{ field: "id", value: user.id }], update: { name: user.name + "-last" }, }); if (!result) throw new Error("No result"); users[i]!.name = result.name; users[i]!.updatedAt = result.updatedAt; } const result = await adapter.findMany<User & { numericField: number }>({ model: "user", sortBy: { field: "numericField", direction: "asc" }, limit: 2, offset: 2, where: [{ field: "name", value: "last", operator: "ends_with" }], }); // Order of operation for most DBs: // FROM → WHERE → SORT BY → OFFSET → LIMIT let expectedResult: any[] = []; expectedResult = users .filter((user) => user.name.endsWith("last")) .sort((a, b) => a.numericField - b.numericField) .slice(2, 4); try { expect(result.length).toBe(2); expect(result).toEqual(expectedResult); } catch (error) { console.log(`--------------------------------`); console.log(`results:`); console.log(result.map((x) => x.id)); console.log(`expected results, sorted:`); console.log( users .filter((x) => x.name.toString().endsWith("last")) .map((x) => x.numericField) .sort((a, b) => a - b), ); console.log(`expected results, sorted + offset:`); console.log( users .filter((x) => x.name.toString().endsWith("last")) .map((x) => x.numericField) .sort((a, b) => a - b) .slice(2, 4), ); console.log(`--------------------------------`); console.log("FAIL", error); console.log(`--------------------------------`); throw error; } }, "update - should update a model": async () => { const [user] = await insertRandom("user"); const result = await adapter.update<User>({ model: "user", where: [{ field: "id", value: user.id }], update: { name: "test-name" }, }); const expectedResult = { ...user, name: "test-name", }; // because of `onUpdate` hook, the updatedAt field will be different result!.updatedAt = user.updatedAt; expect(result).toEqual(expectedResult); const findResult = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: user.id }], }); // because of `onUpdate` hook, the updatedAt field will be different findResult!.updatedAt = user.updatedAt; expect(findResult).toEqual(expectedResult); }, "updateMany - should update all models when where is empty": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); await adapter.updateMany({ model: "user", where: [], update: { name: "test-name" }, }); const result = await adapter.findMany<User>({ model: "user", }); expect(sortModels(result)).toEqual( sortModels(users).map((user, i) => ({ ...user, name: "test-name", updatedAt: sortModels(result)[i]!.updatedAt, })), ); }, "updateMany - should update many models with a specific where": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); await adapter.updateMany({ model: "user", where: [{ field: "id", value: users[0]!.id }], update: { name: "test-name" }, }); const result = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: users[0]!.id }], }); expect(result).toEqual({ ...users[0], name: "test-name", updatedAt: result!.updatedAt, }); }, "updateMany - should update many models with a multiple where": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); await adapter.updateMany({ model: "user", where: [ { field: "id", value: users[0]!.id, connector: "OR" }, { field: "id", value: users[1]!.id, connector: "OR" }, ], update: { name: "test-name" }, }); const result = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: users[0]!.id }], }); expect(result).toEqual({ ...users[0], name: "test-name", updatedAt: result!.updatedAt, }); }, "delete - should delete a model": async () => { const [user] = await insertRandom("user"); await adapter.delete({ model: "user", where: [{ field: "id", value: user.id }], }); const result = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: user.id }], }); expect(result).toBeNull(); }, "delete - should not throw on record not found": async () => { await expect( adapter.delete({ model: "user", where: [{ field: "id", value: "100000" }], }), ).resolves.not.toThrow(); }, "deleteMany - should delete many models": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); await adapter.deleteMany({ model: "user", where: [ { field: "id", value: users[0]!.id, connector: "OR" }, { field: "id", value: users[1]!.id, connector: "OR" }, ], }); const result = await adapter.findMany<User>({ model: "user", }); expect(sortModels(result)).toEqual(sortModels(users.slice(2))); }, "deleteMany - starts_with should not interpret regex patterns": async () => { // Create a user whose name literally starts with the regex-like prefix const userTemplate = await generate("user"); const literalRegexUser = await adapter.create<User>({ model: "user", data: { ...userTemplate, name: ".*danger", }, forceAllowId: true, }); // Also create some normal users that do NOT start with ".*" const normalUsers = (await insertRandom("user", 3)).map((x) => x[0]); await adapter.deleteMany({ model: "user", where: [{ field: "name", value: ".*", operator: "starts_with" }], }); // The literal ".*danger" user should be deleted const deleted = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: literalRegexUser.id }], }); expect(deleted).toBeNull(); // Normal users should remain for (const user of normalUsers) { const stillThere = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: user.id }], }); expect(stillThere).not.toBeNull(); } }, "deleteMany - ends_with should not interpret regex patterns": async () => { // Create a user whose name literally ends with the regex-like suffix const userTemplate = await generate("user"); const literalRegexUser = await adapter.create<User>({ model: "user", data: { ...userTemplate, name: "danger.*", }, forceAllowId: true, }); const normalUsers = (await insertRandom("user", 3)).map((x) => x[0]); await adapter.deleteMany({ model: "user", where: [{ field: "name", value: ".*", operator: "ends_with" }], }); const deleted = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: literalRegexUser.id }], }); expect(deleted).toBeNull(); for (const user of normalUsers) { const stillThere = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: user.id }], }); expect(stillThere).not.toBeNull(); } }, "deleteMany - contains should not interpret regex patterns": async () => { // Create a user whose name literally contains the regex-like pattern const userTemplate = await generate("user"); const literalRegexUser = await adapter.create<User>({ model: "user", data: { ...userTemplate, name: "prefix-.*-suffix", }, forceAllowId: true, }); const normalUsers = (await insertRandom("user", 3)).map((x) => x[0]); await adapter.deleteMany({ model: "user", where: [{ field: "name", value: ".*", operator: "contains" }], }); const deleted = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: literalRegexUser.id }], }); expect(deleted).toBeNull(); for (const user of normalUsers) { const stillThere = await adapter.findOne<User>({ model: "user", where: [{ field: "id", value: user.id }], }); expect(stillThere).not.toBeNull(); } }, "deleteMany - should delete many models with numeric values": async () => { let i = 0; await modifyBetterAuthOptions( { user: { additionalFields: { numericField: { type: "number", defaultValue() { return i++; }, }, }, }, }, true, ); const users = (await insertRandom("user", 3)).map( (x) => x[0], ) as (User & { numericField: number })[]; if (!users[0] || !users[1] || !users[2]) { expect(false).toBe(true); throw new Error("Users not found"); } expect(users[0].numericField).toEqual(0); expect(users[1].numericField).toEqual(1); expect(users[2].numericField).toEqual(2); await adapter.deleteMany({ model: "user", where: [ { field: "numericField", value: users[0].numericField, operator: "gt", }, ], }); const result = await adapter.findMany<User>({ model: "user", }); expect(result).toEqual([users[0]]); }, "deleteMany - should delete many models with boolean values": async () => { const users = (await insertRandom("user", 3)).map((x) => x[0]); // in this test, we have 3 users, two of which have emailVerified set to true and one to false // delete all that has emailVerified set to true, and expect users[1] to be the only one left if (!users[0] || !users[1] || !users[2]) { expect(false).toBe(true); throw new Error("Users not found"); } await adapter.updateMany({ model: "user", where: [], update: { emailVerified: true }, }); await adapter.update({ model: "user", where: [{ field: "id", value: users[1].id }], update: { emailVerified: false }, }); await adapter.deleteMany({ model: "user", where: [{ field: "emailVerified", value: true }], }); const result = await adapter.findMany<User>({ model: "user", }); expect(result).toHaveLength(1); expect(result.find((user) => user.id === users[0]?.id)).toBeUndefined(); expect(result.find((user) => user.id === users[1]?.id)).toBeDefined(); expect(result.find((user) => user.id === users[2]?.id)).toBeUndefined(); }, "count - should count many models": async () => { const users = await insertRandom("user", 15); const result = await adapter.count({ model: "user", }); expect(result).toEqual(users.length); }, "count - should return 0 with no rows to count": async () => { const result = await adapter.count({ model: "user", }); expect(result).toEqual(0); }, "count - should count with where clause": async () => { const users = (await insertRandom("user", 15)).map((x) => x[0]); const result = await adapter.count({ model: "user", where: [ { field: "id", value: users[2]!.id, connector: "OR" }, { field: "id", value: users[3]!.id, connector: "OR" }, ], }); expect(result).toEqual(2); }, "update - should correctly return record when updating a field used in where clause": async () => { // This tests the fix for MySQL where updating a field that's in the where clause // would previously fail to find the record using the old value const [user] = await insertRandom("user"); const originalEmail = user.email; // Update the email, using the old email in the where clause const result = await adapter.update<User>({ model: "user", where: [{ field: "email", value: originalEmail }], update: { email: "[email protected]" }, }); // Should return the updated record with the new email expect(result).toBeDefined(); expect(result!.email).toBe("[email protected]"); expect(result!.id).toBe(user.id); // Verify the update persisted by finding with new email const foundUser = await adapter.findOne<User>({ model: "user", where: [{ field: "email", value: "[email protected]" }], }); expect(foundUser).toBeDefined(); expect(foundUser!.id).toBe(user.id); // Old email should not exist const oldUser = await adapter.findOne<User>({ model: "user", where: [{ field: "email", value: originalEmail }], }); expect(oldUser).toBeNull(); }, "update - should handle updating multiple fields including where clause field": async () => { const [user] = await insertRandom("user"); const originalEmail = user.email; const result = await adapter.update<User>({ model: "user", where: [{ field: "email", value: originalEmail }], update: { email: "[email protected]", name: "Updated Name", emailVerified: true, }, }); expect(result!.email).toBe("[email protected]"); expect(result!.name).toBe("Updated Name"); expect(result!.emailVerified).toBe(true); expect(result!.id).toBe(user.id); }, "update - should work when updated field is not in where clause": async () => { // Regression test: ensure normal updates still work const [user] = await insertRandom("user"); const result = await adapter.update<User>({ model: "user", where: [{ field: "email", value: user.email }], update: { name: "Updated Name Only" }, }); expect(result!.name).toBe("Updated Name Only"); expect(result!.email).toBe(user.email); // Should remain unchanged expect(result!.id).toBe(user.id); }, }; }; ``` -------------------------------------------------------------------------------- /packages/stripe/src/index.ts: -------------------------------------------------------------------------------- ```typescript import { type GenericEndpointContext, type BetterAuthPlugin, logger, } from "better-auth"; import { createAuthEndpoint, createAuthMiddleware } from "better-auth/plugins"; import Stripe from "stripe"; import { type Stripe as StripeType } from "stripe"; import * as z from "zod/v4"; import { sessionMiddleware, APIError, originCheck, getSessionFromCtx, } from "better-auth/api"; import { onCheckoutSessionCompleted, onSubscriptionDeleted, onSubscriptionUpdated, } from "./hooks"; import type { InputSubscription, StripeOptions, StripePlan, Subscription, } from "./types"; import { getPlanByName, getPlanByPriceInfo, getPlans } from "./utils"; import { getSchema } from "./schema"; import { defu } from "defu"; import { defineErrorCodes } from "@better-auth/core/utils"; const STRIPE_ERROR_CODES = defineErrorCodes({ SUBSCRIPTION_NOT_FOUND: "Subscription not found", SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found", ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan", UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer", FAILED_TO_FETCH_PLANS: "Failed to fetch plans", EMAIL_VERIFICATION_REQUIRED: "Email verification is required before you can subscribe to a plan", SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active", SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION: "Subscription is not scheduled for cancellation", }); const getUrl = (ctx: GenericEndpointContext, url: string) => { if (url.startsWith("http")) { return url; } return `${ctx.context.options.baseURL}${ url.startsWith("/") ? url : `/${url}` }`; }; async function resolvePriceIdFromLookupKey( stripeClient: Stripe, lookupKey: string, ): Promise<string | undefined> { if (!lookupKey) return undefined; const prices = await stripeClient.prices.list({ lookup_keys: [lookupKey], active: true, limit: 1, }); return prices.data[0]?.id; } export const stripe = <O extends StripeOptions>(options: O) => { const client = options.stripeClient; const referenceMiddleware = ( action: | "upgrade-subscription" | "list-subscription" | "cancel-subscription" | "restore-subscription" | "billing-portal", ) => createAuthMiddleware(async (ctx) => { const session = ctx.context.session; if (!session) { throw new APIError("UNAUTHORIZED"); } const referenceId = ctx.body?.referenceId || ctx.query?.referenceId || session.user.id; if (ctx.body?.referenceId && !options.subscription?.authorizeReference) { logger.error( `Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`, ); throw new APIError("BAD_REQUEST", { message: "Reference id is not allowed. Read server logs for more details.", }); } const isAuthorized = ctx.body?.referenceId ? await options.subscription?.authorizeReference?.( { user: session.user, session: session.session, referenceId, action, }, ctx, ) : true; if (!isAuthorized) { throw new APIError("UNAUTHORIZED", { message: "Unauthorized", }); } }); const subscriptionEndpoints = { /** * ### Endpoint * * POST `/subscription/upgrade` * * ### API Methods * * **server:** * `auth.api.upgradeSubscription` * * **client:** * `authClient.subscription.upgrade` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-upgrade) */ upgradeSubscription: createAuthEndpoint( "/subscription/upgrade", { method: "POST", body: z.object({ /** * The name of the plan to subscribe */ plan: z.string().meta({ description: 'The name of the plan to upgrade to. Eg: "pro"', }), /** * If annual plan should be applied. */ annual: z .boolean() .meta({ description: "Whether to upgrade to an annual plan. Eg: true", }) .optional(), /** * Reference id of the subscription to upgrade * This is used to identify the subscription to upgrade * If not provided, the user's id will be used */ referenceId: z .string() .meta({ description: 'Reference id of the subscription to upgrade. Eg: "123"', }) .optional(), /** * This is to allow a specific subscription to be upgrade. * If subscription id is provided, and subscription isn't found, * it'll throw an error. */ subscriptionId: z .string() .meta({ description: 'The id of the subscription to upgrade. Eg: "sub_123"', }) .optional(), /** * Any additional data you want to store in your database * subscriptions */ metadata: z.record(z.string(), z.any()).optional(), /** * If a subscription */ seats: z .number() .meta({ description: "Number of seats to upgrade to (if applicable). Eg: 1", }) .optional(), /** * Success URL to redirect back after successful subscription */ successUrl: z .string() .meta({ description: 'Callback URL to redirect back after successful subscription. Eg: "https://example.com/success"', }) .default("/"), /** * Cancel URL */ cancelUrl: z .string() .meta({ description: 'If set, checkout shows a back button and customers will be directed here if they cancel payment. Eg: "https://example.com/pricing"', }) .default("/"), /** * Return URL */ returnUrl: z .string() .meta({ description: 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"', }) .optional(), /** * Disable Redirect */ disableRedirect: z .boolean() .meta({ description: "Disable redirect after successful subscription. Eg: true", }) .default(false), }), use: [ sessionMiddleware, originCheck((c) => { return [c.body.successURL as string, c.body.cancelURL as string]; }), referenceMiddleware("upgrade-subscription"), ], }, async (ctx) => { const { user, session } = ctx.context.session; if ( !user.emailVerified && options.subscription?.requireEmailVerification ) { throw new APIError("BAD_REQUEST", { message: STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED, }); } const referenceId = ctx.body.referenceId || user.id; const plan = await getPlanByName(options, ctx.body.plan); if (!plan) { throw new APIError("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND, }); } const subscriptionToUpdate = ctx.body.subscriptionId ? await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [ { field: "id", value: ctx.body.subscriptionId, connector: "OR", }, { field: "stripeSubscriptionId", value: ctx.body.subscriptionId, connector: "OR", }, ], }) : referenceId ? await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [{ field: "referenceId", value: referenceId }], }) : null; if (ctx.body.subscriptionId && !subscriptionToUpdate) { throw new APIError("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, }); } let customerId = subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId; if (!customerId) { try { // Try to find existing Stripe customer by email const existingCustomers = await client.customers.list({ email: user.email, limit: 1, }); let stripeCustomer = existingCustomers.data[0]; if (!stripeCustomer) { stripeCustomer = await client.customers.create({ email: user.email, name: user.name, metadata: { ...ctx.body.metadata, userId: user.id, }, }); } // Update local DB with Stripe customer ID await ctx.context.adapter.update({ model: "user", update: { stripeCustomerId: stripeCustomer.id, }, where: [ { field: "id", value: user.id, }, ], }); customerId = stripeCustomer.id; } catch (e: any) { ctx.context.logger.error(e); throw new APIError("BAD_REQUEST", { message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER, }); } } const subscriptions = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany<Subscription>({ model: "subscription", where: [ { field: "referenceId", value: ctx.body.referenceId || user.id, }, ], }); const activeOrTrialingSubscription = subscriptions.find( (sub) => sub.status === "active" || sub.status === "trialing", ); const activeSubscriptions = await client.subscriptions .list({ customer: customerId, }) .then((res) => res.data.filter( (sub) => sub.status === "active" || sub.status === "trialing", ), ); const activeSubscription = activeSubscriptions.find((sub) => { // If we have a specific subscription to update, match by ID if ( subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId ) { return ( sub.id === subscriptionToUpdate?.stripeSubscriptionId || sub.id === ctx.body.subscriptionId ); } // Only find subscription for the same referenceId to avoid mixing personal and org subscriptions if (activeOrTrialingSubscription?.stripeSubscriptionId) { return sub.id === activeOrTrialingSubscription.stripeSubscriptionId; } return false; }); // Also find any incomplete subscription that we can reuse const incompleteSubscription = subscriptions.find( (sub) => sub.status === "incomplete", ); if ( activeOrTrialingSubscription && activeOrTrialingSubscription.status === "active" && activeOrTrialingSubscription.plan === ctx.body.plan && activeOrTrialingSubscription.seats === (ctx.body.seats || 1) ) { throw new APIError("BAD_REQUEST", { message: STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN, }); } if (activeSubscription && customerId) { // Find the corresponding database subscription for this Stripe subscription let dbSubscription = await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [ { field: "stripeSubscriptionId", value: activeSubscription.id, }, ], }); // If no database record exists for this Stripe subscription, update the existing one if (!dbSubscription && activeOrTrialingSubscription) { await ctx.context.adapter.update<InputSubscription>({ model: "subscription", update: { stripeSubscriptionId: activeSubscription.id, updatedAt: new Date(), }, where: [ { field: "id", value: activeOrTrialingSubscription.id, }, ], }); dbSubscription = activeOrTrialingSubscription; } // Resolve price ID if using lookup keys let priceIdToUse: string | undefined = undefined; if (ctx.body.annual) { priceIdToUse = plan.annualDiscountPriceId; if (!priceIdToUse && plan.annualDiscountLookupKey) { priceIdToUse = await resolvePriceIdFromLookupKey( client, plan.annualDiscountLookupKey, ); } } else { priceIdToUse = plan.priceId; if (!priceIdToUse && plan.lookupKey) { priceIdToUse = await resolvePriceIdFromLookupKey( client, plan.lookupKey, ); } } if (!priceIdToUse) { throw ctx.error("BAD_REQUEST", { message: "Price ID not found for the selected plan", }); } const { url } = await client.billingPortal.sessions .create({ customer: customerId, return_url: getUrl(ctx, ctx.body.returnUrl || "/"), flow_data: { type: "subscription_update_confirm", after_completion: { type: "redirect", redirect: { return_url: getUrl(ctx, ctx.body.returnUrl || "/"), }, }, subscription_update_confirm: { subscription: activeSubscription.id, items: [ { id: activeSubscription.items.data[0]?.id as string, quantity: ctx.body.seats || 1, price: priceIdToUse, }, ], }, }, }) .catch(async (e) => { throw ctx.error("BAD_REQUEST", { message: e.message, code: e.code, }); }); return ctx.json({ url, redirect: true, }); } let subscription: Subscription | undefined = activeOrTrialingSubscription || incompleteSubscription; if (incompleteSubscription && !activeOrTrialingSubscription) { const updated = await ctx.context.adapter.update<InputSubscription>({ model: "subscription", update: { plan: plan.name.toLowerCase(), seats: ctx.body.seats || 1, updatedAt: new Date(), }, where: [ { field: "id", value: incompleteSubscription.id, }, ], }); subscription = (updated as Subscription) || incompleteSubscription; } if (!subscription) { subscription = await ctx.context.adapter.create< InputSubscription, Subscription >({ model: "subscription", data: { plan: plan.name.toLowerCase(), stripeCustomerId: customerId, status: "incomplete", referenceId, seats: ctx.body.seats || 1, }, }); } if (!subscription) { ctx.context.logger.error("Subscription ID not found"); throw new APIError("INTERNAL_SERVER_ERROR"); } const params = await options.subscription?.getCheckoutSessionParams?.( { user, session, plan, subscription, }, ctx.request, //@ts-expect-error ctx, ); const hasEverTrialed = subscriptions.some((s) => { // Check if user has ever had a trial for any plan (not just the same plan) // This prevents users from getting multiple trials by switching plans const hadTrial = !!(s.trialStart || s.trialEnd) || s.status === "trialing"; return hadTrial; }); const freeTrial = !hasEverTrialed && plan.freeTrial ? { trial_period_days: plan.freeTrial.days } : undefined; let priceIdToUse: string | undefined = undefined; if (ctx.body.annual) { priceIdToUse = plan.annualDiscountPriceId; if (!priceIdToUse && plan.annualDiscountLookupKey) { priceIdToUse = await resolvePriceIdFromLookupKey( client, plan.annualDiscountLookupKey, ); } } else { priceIdToUse = plan.priceId; if (!priceIdToUse && plan.lookupKey) { priceIdToUse = await resolvePriceIdFromLookupKey( client, plan.lookupKey, ); } } const checkoutSession = await client.checkout.sessions .create( { ...(customerId ? { customer: customerId, customer_update: { name: "auto", address: "auto", }, } : { customer_email: session.user.email, }), success_url: getUrl( ctx, `${ ctx.context.baseURL }/subscription/success?callbackURL=${encodeURIComponent( ctx.body.successUrl, )}&subscriptionId=${encodeURIComponent(subscription.id)}`, ), cancel_url: getUrl(ctx, ctx.body.cancelUrl), line_items: [ { price: priceIdToUse, quantity: ctx.body.seats || 1, }, ], subscription_data: { ...freeTrial, }, mode: "subscription", client_reference_id: referenceId, ...params?.params, metadata: { userId: user.id, subscriptionId: subscription.id, referenceId, ...params?.params?.metadata, }, }, params?.options, ) .catch(async (e) => { throw ctx.error("BAD_REQUEST", { message: e.message, code: e.code, }); }); return ctx.json({ ...checkoutSession, redirect: !ctx.body.disableRedirect, }); }, ), cancelSubscriptionCallback: createAuthEndpoint( "/subscription/cancel/callback", { method: "GET", query: z.record(z.string(), z.any()).optional(), use: [originCheck((ctx) => ctx.query.callbackURL)], }, async (ctx) => { if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) { throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); } const session = await getSessionFromCtx<{ stripeCustomerId: string }>( ctx, ); if (!session) { throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); } const { user } = session; const { callbackURL, subscriptionId } = ctx.query; if (user?.stripeCustomerId) { try { const subscription = await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [ { field: "id", value: subscriptionId, }, ], }); if ( !subscription || subscription.cancelAtPeriodEnd || subscription.status === "canceled" ) { throw ctx.redirect(getUrl(ctx, callbackURL)); } const stripeSubscription = await client.subscriptions.list({ customer: user.stripeCustomerId, status: "active", }); const currentSubscription = stripeSubscription.data.find( (sub) => sub.id === subscription.stripeSubscriptionId, ); if (currentSubscription?.cancel_at_period_end === true) { await ctx.context.adapter.update({ model: "subscription", update: { status: currentSubscription?.status, cancelAtPeriodEnd: true, }, where: [ { field: "id", value: subscription.id, }, ], }); await options.subscription?.onSubscriptionCancel?.({ subscription, cancellationDetails: currentSubscription.cancellation_details, stripeSubscription: currentSubscription, event: undefined, }); } } catch (error) { ctx.context.logger.error( "Error checking subscription status from Stripe", error, ); } } throw ctx.redirect(getUrl(ctx, callbackURL)); }, ), /** * ### Endpoint * * POST `/subscription/cancel` * * ### API Methods * * **server:** * `auth.api.cancelSubscription` * * **client:** * `authClient.subscription.cancel` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-cancel) */ cancelSubscription: createAuthEndpoint( "/subscription/cancel", { method: "POST", body: z.object({ referenceId: z .string() .meta({ description: "Reference id of the subscription to cancel. Eg: '123'", }) .optional(), subscriptionId: z .string() .meta({ description: "The id of the subscription to cancel. Eg: 'sub_123'", }) .optional(), returnUrl: z.string().meta({ description: 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"', }), }), use: [ sessionMiddleware, originCheck((ctx) => ctx.body.returnUrl), referenceMiddleware("cancel-subscription"), ], }, async (ctx) => { const referenceId = ctx.body?.referenceId || ctx.context.session.user.id; const subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [ { field: "id", value: ctx.body.subscriptionId, }, ], }) : await ctx.context.adapter .findMany<Subscription>({ model: "subscription", where: [{ field: "referenceId", value: referenceId }], }) .then((subs) => subs.find( (sub) => sub.status === "active" || sub.status === "trialing", ), ); if (!subscription || !subscription.stripeCustomerId) { throw ctx.error("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, }); } const activeSubscriptions = await client.subscriptions .list({ customer: subscription.stripeCustomerId, }) .then((res) => res.data.filter( (sub) => sub.status === "active" || sub.status === "trialing", ), ); if (!activeSubscriptions.length) { /** * If the subscription is not found, we need to delete the subscription * from the database. This is a rare case and should not happen. */ await ctx.context.adapter.deleteMany({ model: "subscription", where: [ { field: "referenceId", value: referenceId, }, ], }); throw ctx.error("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, }); } const activeSubscription = activeSubscriptions.find( (sub) => sub.id === subscription.stripeSubscriptionId, ); if (!activeSubscription) { throw ctx.error("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, }); } const { url } = await client.billingPortal.sessions .create({ customer: subscription.stripeCustomerId, return_url: getUrl( ctx, `${ ctx.context.baseURL }/subscription/cancel/callback?callbackURL=${encodeURIComponent( ctx.body?.returnUrl || "/", )}&subscriptionId=${encodeURIComponent(subscription.id)}`, ), flow_data: { type: "subscription_cancel", subscription_cancel: { subscription: activeSubscription.id, }, }, }) .catch(async (e) => { if (e.message.includes("already set to be cancel")) { /** * incase we missed the event from stripe, we set it manually * this is a rare case and should not happen */ if (!subscription.cancelAtPeriodEnd) { await ctx.context.adapter.update({ model: "subscription", update: { cancelAtPeriodEnd: true, }, where: [ { field: "referenceId", value: referenceId, }, ], }); } } throw ctx.error("BAD_REQUEST", { message: e.message, code: e.code, }); }); return { url, redirect: true, }; }, ), restoreSubscription: createAuthEndpoint( "/subscription/restore", { method: "POST", body: z.object({ referenceId: z .string() .meta({ description: "Reference id of the subscription to restore. Eg: '123'", }) .optional(), subscriptionId: z .string() .meta({ description: "The id of the subscription to restore. Eg: 'sub_123'", }) .optional(), }), use: [sessionMiddleware, referenceMiddleware("restore-subscription")], }, async (ctx) => { const referenceId = ctx.body?.referenceId || ctx.context.session.user.id; const subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [ { field: "id", value: ctx.body.subscriptionId, }, ], }) : await ctx.context.adapter .findMany<Subscription>({ model: "subscription", where: [ { field: "referenceId", value: referenceId, }, ], }) .then((subs) => subs.find( (sub) => sub.status === "active" || sub.status === "trialing", ), ); if (!subscription || !subscription.stripeCustomerId) { throw ctx.error("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, }); } if ( subscription.status != "active" && subscription.status != "trialing" ) { throw ctx.error("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE, }); } if (!subscription.cancelAtPeriodEnd) { throw ctx.error("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION, }); } const activeSubscription = await client.subscriptions .list({ customer: subscription.stripeCustomerId, }) .then( (res) => res.data.filter( (sub) => sub.status === "active" || sub.status === "trialing", )[0], ); if (!activeSubscription) { throw ctx.error("BAD_REQUEST", { message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, }); } try { const newSub = await client.subscriptions.update( activeSubscription.id, { cancel_at_period_end: false, }, ); await ctx.context.adapter.update({ model: "subscription", update: { cancelAtPeriodEnd: false, updatedAt: new Date(), }, where: [ { field: "id", value: subscription.id, }, ], }); return ctx.json(newSub); } catch (error) { ctx.context.logger.error("Error restoring subscription", error); throw new APIError("BAD_REQUEST", { message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER, }); } }, ), /** * ### Endpoint * * GET `/subscription/list` * * ### API Methods * * **server:** * `auth.api.listActiveSubscriptions` * * **client:** * `authClient.subscription.list` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-list) */ listActiveSubscriptions: createAuthEndpoint( "/subscription/list", { method: "GET", query: z.optional( z.object({ referenceId: z .string() .meta({ description: "Reference id of the subscription to list. Eg: '123'", }) .optional(), }), ), use: [sessionMiddleware, referenceMiddleware("list-subscription")], }, async (ctx) => { const subscriptions = await ctx.context.adapter.findMany<Subscription>({ model: "subscription", where: [ { field: "referenceId", value: ctx.query?.referenceId || ctx.context.session.user.id, }, ], }); if (!subscriptions.length) { return []; } const plans = await getPlans(options); if (!plans) { return []; } const subs = subscriptions .map((sub) => { const plan = plans.find( (p) => p.name.toLowerCase() === sub.plan.toLowerCase(), ); return { ...sub, limits: plan?.limits, priceId: plan?.priceId, }; }) .filter((sub) => { return sub.status === "active" || sub.status === "trialing"; }); return ctx.json(subs); }, ), subscriptionSuccess: createAuthEndpoint( "/subscription/success", { method: "GET", query: z.record(z.string(), z.any()).optional(), use: [originCheck((ctx) => ctx.query.callbackURL)], }, async (ctx) => { if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) { throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); } const session = await getSessionFromCtx<{ stripeCustomerId: string }>( ctx, ); if (!session) { throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); } const { user } = session; const { callbackURL, subscriptionId } = ctx.query; const subscription = await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [ { field: "id", value: subscriptionId, }, ], }); if ( subscription?.status === "active" || subscription?.status === "trialing" ) { return ctx.redirect(getUrl(ctx, callbackURL)); } const customerId = subscription?.stripeCustomerId || user.stripeCustomerId; if (customerId) { try { const stripeSubscription = await client.subscriptions .list({ customer: customerId, status: "active", }) .then((res) => res.data[0]); if (stripeSubscription) { const plan = await getPlanByPriceInfo( options, stripeSubscription.items.data[0]?.price.id!, stripeSubscription.items.data[0]?.price.lookup_key!, ); if (plan && subscription) { await ctx.context.adapter.update({ model: "subscription", update: { status: stripeSubscription.status, seats: stripeSubscription.items.data[0]?.quantity || 1, plan: plan.name.toLowerCase(), periodEnd: new Date( stripeSubscription.items.data[0]?.current_period_end! * 1000, ), periodStart: new Date( stripeSubscription.items.data[0]?.current_period_start! * 1000, ), stripeSubscriptionId: stripeSubscription.id, ...(stripeSubscription.trial_start && stripeSubscription.trial_end ? { trialStart: new Date( stripeSubscription.trial_start * 1000, ), trialEnd: new Date( stripeSubscription.trial_end * 1000, ), } : {}), }, where: [ { field: "id", value: subscription.id, }, ], }); } } } catch (error) { ctx.context.logger.error( "Error fetching subscription from Stripe", error, ); } } throw ctx.redirect(getUrl(ctx, callbackURL)); }, ), createBillingPortal: createAuthEndpoint( "/subscription/billing-portal", { method: "POST", body: z.object({ locale: z .custom<StripeType.Checkout.Session.Locale>((localization) => { return typeof localization === "string"; }) .optional(), referenceId: z.string().optional(), returnUrl: z.string().default("/"), }), use: [ sessionMiddleware, originCheck((ctx) => ctx.body.returnUrl), referenceMiddleware("billing-portal"), ], }, async (ctx) => { const { user } = ctx.context.session; const referenceId = ctx.body.referenceId || user.id; let customerId = user.stripeCustomerId; if (!customerId) { const subscription = await ctx.context.adapter .findMany<Subscription>({ model: "subscription", where: [ { field: "referenceId", value: referenceId, }, ], }) .then((subs) => subs.find( (sub) => sub.status === "active" || sub.status === "trialing", ), ); customerId = subscription?.stripeCustomerId; } if (!customerId) { throw new APIError("BAD_REQUEST", { message: "No Stripe customer found for this user", }); } try { const { url } = await client.billingPortal.sessions.create({ locale: ctx.body.locale, customer: customerId, return_url: getUrl(ctx, ctx.body.returnUrl), }); return ctx.json({ url, redirect: true, }); } catch (error: any) { ctx.context.logger.error( "Error creating billing portal session", error, ); throw new APIError("BAD_REQUEST", { message: error.message, }); } }, ), } as const; return { id: "stripe", endpoints: { stripeWebhook: createAuthEndpoint( "/stripe/webhook", { method: "POST", metadata: { isAction: false, }, cloneRequest: true, //don't parse the body disableBody: true, }, async (ctx) => { if (!ctx.request?.body) { throw new APIError("INTERNAL_SERVER_ERROR"); } const buf = await ctx.request.text(); const sig = ctx.request.headers.get("stripe-signature") as string; const webhookSecret = options.stripeWebhookSecret; let event: Stripe.Event; try { if (!sig || !webhookSecret) { throw new APIError("BAD_REQUEST", { message: "Stripe webhook secret not found", }); } event = await client.webhooks.constructEventAsync( buf, sig, webhookSecret, ); } catch (err: any) { ctx.context.logger.error(`${err.message}`); throw new APIError("BAD_REQUEST", { message: `Webhook Error: ${err.message}`, }); } if (!event) { throw new APIError("BAD_REQUEST", { message: "Failed to construct event", }); } try { switch (event.type) { case "checkout.session.completed": await onCheckoutSessionCompleted(ctx, options, event); await options.onEvent?.(event); break; case "customer.subscription.updated": await onSubscriptionUpdated(ctx, options, event); await options.onEvent?.(event); break; case "customer.subscription.deleted": await onSubscriptionDeleted(ctx, options, event); await options.onEvent?.(event); break; default: await options.onEvent?.(event); break; } } catch (e: any) { ctx.context.logger.error( `Stripe webhook failed. Error: ${e.message}`, ); throw new APIError("BAD_REQUEST", { message: "Webhook error: See server logs for more information.", }); } return ctx.json({ success: true }); }, ), ...((options.subscription?.enabled ? subscriptionEndpoints : {}) as O["subscription"] extends { enabled: boolean; } ? typeof subscriptionEndpoints : {}), }, init(ctx) { return { options: { databaseHooks: { user: { create: { async after(user, ctx) { if (ctx && options.createCustomerOnSignUp) { let extraCreateParams: Partial<Stripe.CustomerCreateParams> = {}; if (options.getCustomerCreateParams) { extraCreateParams = await options.getCustomerCreateParams( user, ctx, ); } const params: Stripe.CustomerCreateParams = defu( { email: user.email, name: user.name, metadata: { userId: user.id, }, }, extraCreateParams, ); const stripeCustomer = await client.customers.create(params); await ctx.context.internalAdapter.updateUser(user.id, { stripeCustomerId: stripeCustomer.id, }); await options.onCustomerCreate?.( { stripeCustomer, user: { ...user, stripeCustomerId: stripeCustomer.id, }, }, ctx, ); } }, }, update: { async after(user, ctx) { if (!ctx) return; try { // Cast user to include stripeCustomerId (added by the stripe plugin schema) const userWithStripe = user as typeof user & { stripeCustomerId?: string; }; // Only proceed if user has a Stripe customer ID if (!userWithStripe.stripeCustomerId) return; // Get the user from the database to check if email actually changed // The 'user' parameter here is the freshly updated user // We need to check if the Stripe customer's email matches const stripeCustomer = await client.customers.retrieve( userWithStripe.stripeCustomerId, ); // Check if customer was deleted if (stripeCustomer.deleted) { ctx.context.logger.warn( `Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`, ); return; } // If Stripe customer email doesn't match the user's current email, update it if (stripeCustomer.email !== user.email) { await client.customers.update( userWithStripe.stripeCustomerId, { email: user.email, }, ); ctx.context.logger.info( `Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`, ); } } catch (e: any) { // Ignore errors - this is a best-effort sync // Email might have been deleted or Stripe customer might not exist ctx.context.logger.error( `Failed to sync email to Stripe customer: ${e.message}`, e, ); } }, }, }, }, }, }; }, schema: getSchema(options), } satisfies BetterAuthPlugin; }; export type { Subscription, StripePlan }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/admin/admin.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { APIError, getSessionFromCtx } from "../../api"; import { createAuthEndpoint, createAuthMiddleware, } from "@better-auth/core/middleware"; import { type Session } from "../../types"; import type { BetterAuthPlugin } from "@better-auth/core"; import type { Where } from "@better-auth/core/db/adapter"; import { deleteSessionCookie, setSessionCookie } from "../../cookies"; import { getDate } from "../../utils/date"; import { getEndpointResponse } from "../../utils/plugin-helper"; import { mergeSchema, parseUserOutput } from "../../db/schema"; import { type AccessControl } from "../access"; import { ADMIN_ERROR_CODES } from "./error-codes"; import { defaultStatements } from "./access"; import { hasPermission } from "./has-permission"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { schema } from "./schema"; import type { AdminOptions, InferAdminRolesFromOption, SessionWithImpersonatedBy, UserWithRole, } from "./types"; function parseRoles(roles: string | string[]): string { return Array.isArray(roles) ? roles.join(",") : roles; } export const admin = <O extends AdminOptions>(options?: O) => { const opts = { defaultRole: options?.defaultRole ?? "user", adminRoles: options?.adminRoles ?? ["admin"], bannedUserMessage: options?.bannedUserMessage ?? "You have been banned from this application. Please contact support if you believe this is an error.", ...options, }; type DefaultStatements = typeof defaultStatements; type Statements = O["ac"] extends AccessControl<infer S> ? S : DefaultStatements; type PermissionType = { [key in keyof Statements]?: Array< Statements[key] extends readonly unknown[] ? Statements[key][number] : never >; }; type PermissionExclusive = | { /** * @deprecated Use `permissions` instead */ permission: PermissionType; permissions?: never; } | { permissions: PermissionType; permission?: never; }; /** * Ensures a valid session, if not will throw. * Will also provide additional types on the user to include role types. */ const adminMiddleware = createAuthMiddleware(async (ctx) => { const session = await getSessionFromCtx(ctx); if (!session) { throw new APIError("UNAUTHORIZED"); } return { session, } as { session: { user: UserWithRole; session: Session; }; }; }); return { id: "admin", init() { return { options: { databaseHooks: { user: { create: { async before(user) { return { data: { role: options?.defaultRole ?? "user", ...user, }, }; }, }, }, session: { create: { async before(session, ctx) { if (!ctx) { return; } const user = (await ctx.context.internalAdapter.findUserById( session.userId, )) as UserWithRole; if (user.banned) { if ( user.banExpires && new Date(user.banExpires).getTime() < Date.now() ) { await ctx.context.internalAdapter.updateUser( session.userId, { banned: false, banReason: null, banExpires: null, }, ); return; } if ( ctx && (ctx.path.startsWith("/callback") || ctx.path.startsWith("/oauth2/callback")) ) { const redirectURI = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`; throw ctx.redirect( `${redirectURI}?error=banned&error_description=${opts.bannedUserMessage}`, ); } throw new APIError("FORBIDDEN", { message: opts.bannedUserMessage, code: "BANNED_USER", }); } }, }, }, }, }, }; }, hooks: { after: [ { matcher(context) { return context.path === "/list-sessions"; }, handler: createAuthMiddleware(async (ctx) => { const response = await getEndpointResponse<SessionWithImpersonatedBy[]>(ctx); if (!response) { return; } const newJson = response.filter((session) => { return !session.impersonatedBy; }); return ctx.json(newJson); }), }, ], }, endpoints: { /** * ### Endpoint * * POST `/admin/set-role` * * ### API Methods * * **server:** * `auth.api.setRole` * * **client:** * `authClient.admin.setRole` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-set-role) */ setRole: createAuthEndpoint( "/admin/set-role", { method: "POST", body: z.object({ userId: z.coerce.string().meta({ description: "The user id", }), role: z .union([ z.string().meta({ description: "The role to set. `admin` or `user` by default", }), z.array( z.string().meta({ description: "The roles to set. `admin` or `user` by default", }), ), ]) .meta({ description: "The role to set, this can be a string or an array of strings. Eg: `admin` or `[admin, user]`", }), }), requireHeaders: true, use: [adminMiddleware], metadata: { openapi: { operationId: "setRole", summary: "Set the role of a user", description: "Set the role of a user", responses: { 200: { description: "User role updated", content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User", }, }, }, }, }, }, }, }, $Infer: { body: {} as { userId: string; role: | InferAdminRolesFromOption<O> | InferAdminRolesFromOption<O>[]; }, }, }, }, async (ctx) => { const canSetRole = hasPermission({ userId: ctx.context.session.user.id, role: ctx.context.session.user.role, options: opts, permissions: { user: ["set-role"], }, }); if (!canSetRole) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE, }); } const updatedUser = await ctx.context.internalAdapter.updateUser( ctx.body.userId, { role: parseRoles(ctx.body.role), }, ctx, ); return ctx.json({ user: updatedUser as UserWithRole, }); }, ), getUser: createAuthEndpoint( "/admin/get-user", { method: "GET", query: z.object({ id: z.string().meta({ description: "The id of the User", }), }), use: [adminMiddleware], metadata: { openapi: { operationId: "getUser", summary: "Get an existing user", description: "Get an existing user", responses: { 200: { description: "User", content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const { id } = ctx.query; const canGetUser = hasPermission({ userId: ctx.context.session.user.id, role: ctx.context.session.user.role, options: opts, permissions: { user: ["get"], }, }); if (!canGetUser) { throw ctx.error("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_GET_USER, code: "YOU_ARE_NOT_ALLOWED_TO_GET_USER", }); } const user = await ctx.context.internalAdapter.findUserById(id); if (!user) { throw new APIError("NOT_FOUND", { message: BASE_ERROR_CODES.USER_NOT_FOUND, }); } return parseUserOutput(ctx.context.options, user); }, ), /** * ### Endpoint * * POST `/admin/create-user` * * ### API Methods * * **server:** * `auth.api.createUser` * * **client:** * `authClient.admin.createUser` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-create-user) */ createUser: createAuthEndpoint( "/admin/create-user", { method: "POST", body: z.object({ email: z.string().meta({ description: "The email of the user", }), password: z.string().meta({ description: "The password of the user", }), name: z.string().meta({ description: "The name of the user", }), role: z .union([ z.string().meta({ description: "The role of the user", }), z.array( z.string().meta({ description: "The roles of user", }), ), ]) .optional() .meta({ description: `A string or array of strings representing the roles to apply to the new user. Eg: \"user\"`, }), /** * extra fields for user */ data: z.record(z.string(), z.any()).optional().meta({ description: "Extra fields for the user. Including custom additional fields.", }), }), metadata: { openapi: { operationId: "createUser", summary: "Create a new user", description: "Create a new user", responses: { 200: { description: "User created", content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User", }, }, }, }, }, }, }, }, $Infer: { body: {} as { email: string; password: string; name: string; role?: | InferAdminRolesFromOption<O> | InferAdminRolesFromOption<O>[]; data?: Record<string, any>; }, }, }, }, async (ctx) => { const session = await getSessionFromCtx<{ role: string }>(ctx); if (!session && (ctx.request || ctx.headers)) { throw ctx.error("UNAUTHORIZED"); } if (session) { const canCreateUser = hasPermission({ userId: session.user.id, role: session.user.role, options: opts, permissions: { user: ["create"], }, }); if (!canCreateUser) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS, }); } } const existUser = await ctx.context.internalAdapter.findUserByEmail( ctx.body.email, ); if (existUser) { throw new APIError("BAD_REQUEST", { message: ADMIN_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, }); } const user = await ctx.context.internalAdapter.createUser<UserWithRole>( { email: ctx.body.email, name: ctx.body.name, role: (ctx.body.role && parseRoles(ctx.body.role)) ?? options?.defaultRole ?? "user", ...ctx.body.data, }, ctx, ); if (!user) { throw new APIError("INTERNAL_SERVER_ERROR", { message: ADMIN_ERROR_CODES.FAILED_TO_CREATE_USER, }); } const hashedPassword = await ctx.context.password.hash( ctx.body.password, ); await ctx.context.internalAdapter.linkAccount( { accountId: user.id, providerId: "credential", password: hashedPassword, userId: user.id, }, ctx, ); return ctx.json({ user: user as UserWithRole, }); }, ), /** * ### Endpoint * * POST `/admin/update-user` * * ### API Methods * * **server:** * `auth.api.adminUpdateUser` * * **client:** * `authClient.admin.updateUser` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-update-user) */ adminUpdateUser: createAuthEndpoint( "/admin/update-user", { method: "POST", body: z.object({ userId: z.coerce.string().meta({ description: "The user id", }), data: z.record(z.any(), z.any()).meta({ description: "The user data to update", }), }), use: [adminMiddleware], metadata: { openapi: { operationId: "updateUser", summary: "Update a user", description: "Update a user's details", responses: { 200: { description: "User updated", content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const canUpdateUser = hasPermission({ userId: ctx.context.session.user.id, role: ctx.context.session.user.role, options: opts, permissions: { user: ["update"], }, }); if (!canUpdateUser) { throw ctx.error("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS, code: "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS", }); } if (Object.keys(ctx.body.data).length === 0) { throw new APIError("BAD_REQUEST", { message: ADMIN_ERROR_CODES.NO_DATA_TO_UPDATE, }); } if (ctx.body.data?.role) { ctx.body.data.role = parseRoles(ctx.body.data.role); } const updatedUser = await ctx.context.internalAdapter.updateUser( ctx.body.userId, ctx.body.data, ctx, ); return ctx.json(updatedUser as UserWithRole); }, ), listUsers: createAuthEndpoint( "/admin/list-users", { method: "GET", use: [adminMiddleware], query: z.object({ searchValue: z.string().optional().meta({ description: 'The value to search for. Eg: "some name"', }), searchField: z .enum(["email", "name"]) .meta({ description: 'The field to search in, defaults to email. Can be `email` or `name`. Eg: "name"', }) .optional(), searchOperator: z .enum(["contains", "starts_with", "ends_with"]) .meta({ description: 'The operator to use for the search. Can be `contains`, `starts_with` or `ends_with`. Eg: "contains"', }) .optional(), limit: z .string() .meta({ description: "The number of users to return", }) .or(z.number()) .optional(), offset: z .string() .meta({ description: "The offset to start from", }) .or(z.number()) .optional(), sortBy: z .string() .meta({ description: "The field to sort by", }) .optional(), sortDirection: z .enum(["asc", "desc"]) .meta({ description: "The direction to sort by", }) .optional(), filterField: z .string() .meta({ description: "The field to filter by", }) .optional(), filterValue: z .string() .meta({ description: "The value to filter by", }) .or(z.number()) .or(z.boolean()) .optional(), filterOperator: z .enum(["eq", "ne", "lt", "lte", "gt", "gte", "contains"]) .meta({ description: "The operator to use for the filter", }) .optional(), }), metadata: { openapi: { operationId: "listUsers", summary: "List users", description: "List users", responses: { 200: { description: "List of users", content: { "application/json": { schema: { type: "object", properties: { users: { type: "array", items: { $ref: "#/components/schemas/User", }, }, total: { type: "number", }, limit: { type: "number", }, offset: { type: "number", }, }, required: ["users", "total"], }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; const canListUsers = hasPermission({ userId: ctx.context.session.user.id, role: session.user.role, options: opts, permissions: { user: ["list"], }, }); if (!canListUsers) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_USERS, }); } const where: Where[] = []; if (ctx.query?.searchValue) { where.push({ field: ctx.query.searchField || "email", operator: ctx.query.searchOperator || "contains", value: ctx.query.searchValue, }); } if (ctx.query?.filterValue) { where.push({ field: ctx.query.filterField || "email", operator: ctx.query.filterOperator || "eq", value: ctx.query.filterValue, }); } try { const users = await ctx.context.internalAdapter.listUsers( Number(ctx.query?.limit) || undefined, Number(ctx.query?.offset) || undefined, ctx.query?.sortBy ? { field: ctx.query.sortBy, direction: ctx.query.sortDirection || "asc", } : undefined, where.length ? where : undefined, ); const total = await ctx.context.internalAdapter.countTotalUsers( where.length ? where : undefined, ); return ctx.json({ users: users as UserWithRole[], total: total, limit: Number(ctx.query?.limit) || undefined, offset: Number(ctx.query?.offset) || undefined, }); } catch (e) { return ctx.json({ users: [], total: 0, }); } }, ), /** * ### Endpoint * * POST `/admin/list-user-sessions` * * ### API Methods * * **server:** * `auth.api.listUserSessions` * * **client:** * `authClient.admin.listUserSessions` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-list-user-sessions) */ listUserSessions: createAuthEndpoint( "/admin/list-user-sessions", { method: "POST", use: [adminMiddleware], body: z.object({ userId: z.coerce.string().meta({ description: "The user id", }), }), metadata: { openapi: { operationId: "listUserSessions", summary: "List user sessions", description: "List user sessions", responses: { 200: { description: "List of user sessions", content: { "application/json": { schema: { type: "object", properties: { sessions: { type: "array", items: { $ref: "#/components/schemas/Session", }, }, }, }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; const canListSessions = hasPermission({ userId: ctx.context.session.user.id, role: session.user.role, options: opts, permissions: { session: ["list"], }, }); if (!canListSessions) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS, }); } const sessions: SessionWithImpersonatedBy[] = await ctx.context.internalAdapter.listSessions(ctx.body.userId); return { sessions: sessions, }; }, ), /** * ### Endpoint * * POST `/admin/unban-user` * * ### API Methods * * **server:** * `auth.api.unbanUser` * * **client:** * `authClient.admin.unbanUser` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-unban-user) */ unbanUser: createAuthEndpoint( "/admin/unban-user", { method: "POST", body: z.object({ userId: z.coerce.string().meta({ description: "The user id", }), }), use: [adminMiddleware], metadata: { openapi: { operationId: "unbanUser", summary: "Unban a user", description: "Unban a user", responses: { 200: { description: "User unbanned", content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; const canBanUser = hasPermission({ userId: ctx.context.session.user.id, role: session.user.role, options: opts, permissions: { user: ["ban"], }, }); if (!canBanUser) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_BAN_USERS, }); } const user = await ctx.context.internalAdapter.updateUser( ctx.body.userId, { banned: false, banExpires: null, banReason: null, updatedAt: new Date(), }, ); return ctx.json({ user: user, }); }, ), /** * ### Endpoint * * POST `/admin/ban-user` * * ### API Methods * * **server:** * `auth.api.banUser` * * **client:** * `authClient.admin.banUser` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-ban-user) */ banUser: createAuthEndpoint( "/admin/ban-user", { method: "POST", body: z.object({ userId: z.coerce.string().meta({ description: "The user id", }), /** * Reason for the ban */ banReason: z .string() .meta({ description: "The reason for the ban", }) .optional(), /** * Number of seconds until the ban expires */ banExpiresIn: z .number() .meta({ description: "The number of seconds until the ban expires", }) .optional(), }), use: [adminMiddleware], metadata: { openapi: { operationId: "banUser", summary: "Ban a user", description: "Ban a user", responses: { 200: { description: "User banned", content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; const canBanUser = hasPermission({ userId: ctx.context.session.user.id, role: session.user.role, options: opts, permissions: { user: ["ban"], }, }); if (!canBanUser) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_BAN_USERS, }); } const foundUser = await ctx.context.internalAdapter.findUserById( ctx.body.userId, ); if (!foundUser) { throw new APIError("NOT_FOUND", { message: BASE_ERROR_CODES.USER_NOT_FOUND, }); } if (ctx.body.userId === ctx.context.session.user.id) { throw new APIError("BAD_REQUEST", { message: ADMIN_ERROR_CODES.YOU_CANNOT_BAN_YOURSELF, }); } const user = await ctx.context.internalAdapter.updateUser( ctx.body.userId, { banned: true, banReason: ctx.body.banReason || options?.defaultBanReason || "No reason", banExpires: ctx.body.banExpiresIn ? getDate(ctx.body.banExpiresIn, "sec") : options?.defaultBanExpiresIn ? getDate(options.defaultBanExpiresIn, "sec") : undefined, updatedAt: new Date(), }, ctx, ); //revoke all sessions await ctx.context.internalAdapter.deleteSessions(ctx.body.userId); return ctx.json({ user: user, }); }, ), /** * ### Endpoint * * POST `/admin/impersonate-user` * * ### API Methods * * **server:** * `auth.api.impersonateUser` * * **client:** * `authClient.admin.impersonateUser` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-impersonate-user) */ impersonateUser: createAuthEndpoint( "/admin/impersonate-user", { method: "POST", body: z.object({ userId: z.coerce.string().meta({ description: "The user id", }), }), use: [adminMiddleware], metadata: { openapi: { operationId: "impersonateUser", summary: "Impersonate a user", description: "Impersonate a user", responses: { 200: { description: "Impersonation session created", content: { "application/json": { schema: { type: "object", properties: { session: { $ref: "#/components/schemas/Session", }, user: { $ref: "#/components/schemas/User", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const canImpersonateUser = hasPermission({ userId: ctx.context.session.user.id, role: ctx.context.session.user.role, options: opts, permissions: { user: ["impersonate"], }, }); if (!canImpersonateUser) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS, }); } const targetUser = await ctx.context.internalAdapter.findUserById( ctx.body.userId, ); if (!targetUser) { throw new APIError("NOT_FOUND", { message: "User not found", }); } const session = await ctx.context.internalAdapter.createSession( targetUser.id, ctx, true, { impersonatedBy: ctx.context.session.user.id, expiresAt: options?.impersonationSessionDuration ? getDate(options.impersonationSessionDuration, "sec") : getDate(60 * 60, "sec"), // 1 hour }, true, ); if (!session) { throw new APIError("INTERNAL_SERVER_ERROR", { message: ADMIN_ERROR_CODES.FAILED_TO_CREATE_USER, }); } const authCookies = ctx.context.authCookies; deleteSessionCookie(ctx); const dontRememberMeCookie = await ctx.getSignedCookie( ctx.context.authCookies.dontRememberToken.name, ctx.context.secret, ); const adminCookieProp = ctx.context.createAuthCookie("admin_session"); await ctx.setSignedCookie( adminCookieProp.name, `${ctx.context.session.session.token}:${ dontRememberMeCookie || "" }`, ctx.context.secret, authCookies.sessionToken.options, ); await setSessionCookie( ctx, { session: session, user: targetUser, }, true, ); return ctx.json({ session: session, user: targetUser, }); }, ), /** * ### Endpoint * * POST `/admin/stop-impersonating` * * ### API Methods * * **server:** * `auth.api.stopImpersonating` * * **client:** * `authClient.admin.stopImpersonating` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-stop-impersonating) */ stopImpersonating: createAuthEndpoint( "/admin/stop-impersonating", { method: "POST", requireHeaders: true, }, async (ctx) => { const session = await getSessionFromCtx< {}, { impersonatedBy: string; } >(ctx); if (!session) { throw new APIError("UNAUTHORIZED"); } if (!session.session.impersonatedBy) { throw new APIError("BAD_REQUEST", { message: "You are not impersonating anyone", }); } const user = await ctx.context.internalAdapter.findUserById( session.session.impersonatedBy, ); if (!user) { throw new APIError("INTERNAL_SERVER_ERROR", { message: "Failed to find user", }); } const adminCookieName = ctx.context.createAuthCookie("admin_session").name; const adminCookie = await ctx.getSignedCookie( adminCookieName, ctx.context.secret, ); if (!adminCookie) { throw new APIError("INTERNAL_SERVER_ERROR", { message: "Failed to find admin session", }); } const [adminSessionToken, dontRememberMeCookie] = adminCookie?.split(":"); const adminSession = await ctx.context.internalAdapter.findSession( adminSessionToken!, ); if (!adminSession || adminSession.session.userId !== user.id) { throw new APIError("INTERNAL_SERVER_ERROR", { message: "Failed to find admin session", }); } await ctx.context.internalAdapter.deleteSession( session.session.token, ); await setSessionCookie(ctx, adminSession, !!dontRememberMeCookie); return ctx.json(adminSession); }, ), /** * ### Endpoint * * POST `/admin/revoke-user-session` * * ### API Methods * * **server:** * `auth.api.revokeUserSession` * * **client:** * `authClient.admin.revokeUserSession` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-revoke-user-session) */ revokeUserSession: createAuthEndpoint( "/admin/revoke-user-session", { method: "POST", body: z.object({ sessionToken: z.string().meta({ description: "The session token", }), }), use: [adminMiddleware], metadata: { openapi: { operationId: "revokeUserSession", summary: "Revoke a user session", description: "Revoke a user session", responses: { 200: { description: "Session revoked", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; const canRevokeSession = hasPermission({ userId: ctx.context.session.user.id, role: session.user.role, options: opts, permissions: { session: ["revoke"], }, }); if (!canRevokeSession) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS, }); } await ctx.context.internalAdapter.deleteSession( ctx.body.sessionToken, ); return ctx.json({ success: true, }); }, ), /** * ### Endpoint * * POST `/admin/revoke-user-sessions` * * ### API Methods * * **server:** * `auth.api.revokeUserSessions` * * **client:** * `authClient.admin.revokeUserSessions` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-revoke-user-sessions) */ revokeUserSessions: createAuthEndpoint( "/admin/revoke-user-sessions", { method: "POST", body: z.object({ userId: z.coerce.string().meta({ description: "The user id", }), }), use: [adminMiddleware], metadata: { openapi: { operationId: "revokeUserSessions", summary: "Revoke all user sessions", description: "Revoke all user sessions", responses: { 200: { description: "Sessions revoked", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; const canRevokeSession = hasPermission({ userId: ctx.context.session.user.id, role: session.user.role, options: opts, permissions: { session: ["revoke"], }, }); if (!canRevokeSession) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS, }); } await ctx.context.internalAdapter.deleteSessions(ctx.body.userId); return ctx.json({ success: true, }); }, ), /** * ### Endpoint * * POST `/admin/remove-user` * * ### API Methods * * **server:** * `auth.api.removeUser` * * **client:** * `authClient.admin.removeUser` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-remove-user) */ removeUser: createAuthEndpoint( "/admin/remove-user", { method: "POST", body: z.object({ userId: z.coerce.string().meta({ description: "The user id", }), }), use: [adminMiddleware], metadata: { openapi: { operationId: "removeUser", summary: "Remove a user", description: "Delete a user and all their sessions and accounts. Cannot be undone.", responses: { 200: { description: "User removed", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; const canDeleteUser = hasPermission({ userId: ctx.context.session.user.id, role: session.user.role, options: opts, permissions: { user: ["delete"], }, }); if (!canDeleteUser) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS, }); } if (ctx.body.userId === ctx.context.session.user.id) { throw new APIError("BAD_REQUEST", { message: ADMIN_ERROR_CODES.YOU_CANNOT_REMOVE_YOURSELF, }); } const user = await ctx.context.internalAdapter.findUserById( ctx.body.userId, ); if (!user) { throw new APIError("NOT_FOUND", { message: "User not found", }); } await ctx.context.internalAdapter.deleteUser(ctx.body.userId); return ctx.json({ success: true, }); }, ), /** * ### Endpoint * * POST `/admin/set-user-password` * * ### API Methods * * **server:** * `auth.api.setUserPassword` * * **client:** * `authClient.admin.setUserPassword` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-set-user-password) */ setUserPassword: createAuthEndpoint( "/admin/set-user-password", { method: "POST", body: z.object({ newPassword: z.string().meta({ description: "The new password", }), userId: z.coerce.string().meta({ description: "The user id", }), }), use: [adminMiddleware], metadata: { openapi: { operationId: "setUserPassword", summary: "Set a user's password", description: "Set a user's password", responses: { 200: { description: "Password set", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const canSetUserPassword = hasPermission({ userId: ctx.context.session.user.id, role: ctx.context.session.user.role, options: opts, permissions: { user: ["set-password"], }, }); if (!canSetUserPassword) { throw new APIError("FORBIDDEN", { message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD, }); } const hashedPassword = await ctx.context.password.hash( ctx.body.newPassword, ); await ctx.context.internalAdapter.updatePassword( ctx.body.userId, hashedPassword, ); return ctx.json({ status: true, }); }, ), /** * ### Endpoint * * POST `/admin/has-permission` * * ### API Methods * * **server:** * `auth.api.userHasPermission` * * **client:** * `authClient.admin.hasPermission` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-has-permission) */ userHasPermission: createAuthEndpoint( "/admin/has-permission", { method: "POST", body: z .object({ userId: z.coerce.string().optional().meta({ description: `The user id. Eg: "user-id"`, }), role: z.string().optional().meta({ description: `The role to check permission for. Eg: "admin"`, }), }) .and( z.union([ z.object({ permission: z.record(z.string(), z.array(z.string())), permissions: z.undefined(), }), z.object({ permission: z.undefined(), permissions: z.record(z.string(), z.array(z.string())), }), ]), ), metadata: { openapi: { description: "Check if the user has permission", requestBody: { content: { "application/json": { schema: { type: "object", properties: { permission: { type: "object", description: "The permission to check", deprecated: true, }, permissions: { type: "object", description: "The permission to check", }, }, required: ["permissions"], }, }, }, }, responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string", }, success: { type: "boolean", }, }, required: ["success"], }, }, }, }, }, }, $Infer: { body: {} as PermissionExclusive & { userId?: string; role?: InferAdminRolesFromOption<O>; }, }, }, }, async (ctx) => { if (!ctx.body?.permission && !ctx.body?.permissions) { throw new APIError("BAD_REQUEST", { message: "invalid permission check. no permission(s) were passed.", }); } const session = await getSessionFromCtx(ctx); if (!session && (ctx.request || ctx.headers)) { throw new APIError("UNAUTHORIZED"); } if (!session && !ctx.body.userId && !ctx.body.role) { throw new APIError("BAD_REQUEST", { message: "user id or role is required", }); } const user = session?.user || (ctx.body.role ? { id: ctx.body.userId || "", role: ctx.body.role } : null) || ((await ctx.context.internalAdapter.findUserById( ctx.body.userId as string, )) as { role?: string; id: string }); if (!user) { throw new APIError("BAD_REQUEST", { message: "user not found", }); } const result = hasPermission({ userId: user.id, role: user.role, options: options as AdminOptions, permissions: (ctx.body.permissions ?? ctx.body.permission) as any, }); return ctx.json({ error: null, success: result, }); }, ), }, $ERROR_CODES: ADMIN_ERROR_CODES, schema: mergeSchema(schema, opts.schema), options: options as any, } satisfies BetterAuthPlugin; }; ```