This is page 58 of 69. Use http://codebase.md/better-auth/better-auth?lines=true&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-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.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 │ │ │ │ ├── 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 │ │ └── vitest.setup.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 │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── 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 │ │ │ ├── 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/stripe/src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | type GenericEndpointContext, 3 | type BetterAuthPlugin, 4 | logger, 5 | } from "better-auth"; 6 | import { 7 | createAuthEndpoint, 8 | createAuthMiddleware, 9 | } from "@better-auth/core/api"; 10 | import Stripe from "stripe"; 11 | import { type Stripe as StripeType } from "stripe"; 12 | import * as z from "zod/v4"; 13 | import { 14 | sessionMiddleware, 15 | APIError, 16 | originCheck, 17 | getSessionFromCtx, 18 | } from "better-auth/api"; 19 | import { 20 | onCheckoutSessionCompleted, 21 | onSubscriptionDeleted, 22 | onSubscriptionUpdated, 23 | } from "./hooks"; 24 | import type { 25 | InputSubscription, 26 | StripeOptions, 27 | StripePlan, 28 | Subscription, 29 | } from "./types"; 30 | import { getPlanByName, getPlanByPriceInfo, getPlans } from "./utils"; 31 | import { getSchema } from "./schema"; 32 | import { defu } from "defu"; 33 | import { defineErrorCodes } from "@better-auth/core/utils"; 34 | 35 | const STRIPE_ERROR_CODES = defineErrorCodes({ 36 | SUBSCRIPTION_NOT_FOUND: "Subscription not found", 37 | SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found", 38 | ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan", 39 | UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer", 40 | FAILED_TO_FETCH_PLANS: "Failed to fetch plans", 41 | EMAIL_VERIFICATION_REQUIRED: 42 | "Email verification is required before you can subscribe to a plan", 43 | SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active", 44 | SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION: 45 | "Subscription is not scheduled for cancellation", 46 | }); 47 | 48 | const getUrl = (ctx: GenericEndpointContext, url: string) => { 49 | if (url.startsWith("http")) { 50 | return url; 51 | } 52 | return `${ctx.context.options.baseURL}${ 53 | url.startsWith("/") ? url : `/${url}` 54 | }`; 55 | }; 56 | 57 | async function resolvePriceIdFromLookupKey( 58 | stripeClient: Stripe, 59 | lookupKey: string, 60 | ): Promise<string | undefined> { 61 | if (!lookupKey) return undefined; 62 | const prices = await stripeClient.prices.list({ 63 | lookup_keys: [lookupKey], 64 | active: true, 65 | limit: 1, 66 | }); 67 | return prices.data[0]?.id; 68 | } 69 | 70 | export const stripe = <O extends StripeOptions>(options: O) => { 71 | const client = options.stripeClient; 72 | 73 | const referenceMiddleware = ( 74 | action: 75 | | "upgrade-subscription" 76 | | "list-subscription" 77 | | "cancel-subscription" 78 | | "restore-subscription" 79 | | "billing-portal", 80 | ) => 81 | createAuthMiddleware(async (ctx) => { 82 | const session = ctx.context.session; 83 | if (!session) { 84 | throw new APIError("UNAUTHORIZED"); 85 | } 86 | const referenceId = 87 | ctx.body?.referenceId || ctx.query?.referenceId || session.user.id; 88 | 89 | if (ctx.body?.referenceId && !options.subscription?.authorizeReference) { 90 | logger.error( 91 | `Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`, 92 | ); 93 | throw new APIError("BAD_REQUEST", { 94 | message: 95 | "Reference id is not allowed. Read server logs for more details.", 96 | }); 97 | } 98 | /** 99 | * if referenceId is the same as the active session user's id 100 | */ 101 | const sameReference = 102 | ctx.query?.referenceId === session.user.id || 103 | ctx.body?.referenceId === session.user.id; 104 | const isAuthorized = 105 | ctx.body?.referenceId || ctx.query?.referenceId 106 | ? (await options.subscription?.authorizeReference?.( 107 | { 108 | user: session.user, 109 | session: session.session, 110 | referenceId, 111 | action, 112 | }, 113 | ctx, 114 | )) || sameReference 115 | : true; 116 | if (!isAuthorized) { 117 | throw new APIError("UNAUTHORIZED", { 118 | message: "Unauthorized", 119 | }); 120 | } 121 | }); 122 | 123 | const subscriptionEndpoints = { 124 | /** 125 | * ### Endpoint 126 | * 127 | * POST `/subscription/upgrade` 128 | * 129 | * ### API Methods 130 | * 131 | * **server:** 132 | * `auth.api.upgradeSubscription` 133 | * 134 | * **client:** 135 | * `authClient.subscription.upgrade` 136 | * 137 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-upgrade) 138 | */ 139 | upgradeSubscription: createAuthEndpoint( 140 | "/subscription/upgrade", 141 | { 142 | method: "POST", 143 | body: z.object({ 144 | /** 145 | * The name of the plan to subscribe 146 | */ 147 | plan: z.string().meta({ 148 | description: 'The name of the plan to upgrade to. Eg: "pro"', 149 | }), 150 | /** 151 | * If annual plan should be applied. 152 | */ 153 | annual: z 154 | .boolean() 155 | .meta({ 156 | description: "Whether to upgrade to an annual plan. Eg: true", 157 | }) 158 | .optional(), 159 | /** 160 | * Reference id of the subscription to upgrade 161 | * This is used to identify the subscription to upgrade 162 | * If not provided, the user's id will be used 163 | */ 164 | referenceId: z 165 | .string() 166 | .meta({ 167 | description: 168 | 'Reference id of the subscription to upgrade. Eg: "123"', 169 | }) 170 | .optional(), 171 | /** 172 | * This is to allow a specific subscription to be upgrade. 173 | * If subscription id is provided, and subscription isn't found, 174 | * it'll throw an error. 175 | */ 176 | subscriptionId: z 177 | .string() 178 | .meta({ 179 | description: 180 | 'The id of the subscription to upgrade. Eg: "sub_123"', 181 | }) 182 | .optional(), 183 | /** 184 | * Any additional data you want to store in your database 185 | * subscriptions 186 | */ 187 | metadata: z.record(z.string(), z.any()).optional(), 188 | /** 189 | * If a subscription 190 | */ 191 | seats: z 192 | .number() 193 | .meta({ 194 | description: 195 | "Number of seats to upgrade to (if applicable). Eg: 1", 196 | }) 197 | .optional(), 198 | /** 199 | * Success URL to redirect back after successful subscription 200 | */ 201 | successUrl: z 202 | .string() 203 | .meta({ 204 | description: 205 | 'Callback URL to redirect back after successful subscription. Eg: "https://example.com/success"', 206 | }) 207 | .default("/"), 208 | /** 209 | * Cancel URL 210 | */ 211 | cancelUrl: z 212 | .string() 213 | .meta({ 214 | description: 215 | 'If set, checkout shows a back button and customers will be directed here if they cancel payment. Eg: "https://example.com/pricing"', 216 | }) 217 | .default("/"), 218 | /** 219 | * Return URL 220 | */ 221 | returnUrl: z 222 | .string() 223 | .meta({ 224 | description: 225 | 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"', 226 | }) 227 | .optional(), 228 | /** 229 | * Disable Redirect 230 | */ 231 | disableRedirect: z 232 | .boolean() 233 | .meta({ 234 | description: 235 | "Disable redirect after successful subscription. Eg: true", 236 | }) 237 | .default(false), 238 | }), 239 | use: [ 240 | sessionMiddleware, 241 | originCheck((c) => { 242 | return [c.body.successURL as string, c.body.cancelURL as string]; 243 | }), 244 | referenceMiddleware("upgrade-subscription"), 245 | ], 246 | }, 247 | async (ctx) => { 248 | const { user, session } = ctx.context.session; 249 | if ( 250 | !user.emailVerified && 251 | options.subscription?.requireEmailVerification 252 | ) { 253 | throw new APIError("BAD_REQUEST", { 254 | message: STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED, 255 | }); 256 | } 257 | const referenceId = ctx.body.referenceId || user.id; 258 | const plan = await getPlanByName(options, ctx.body.plan); 259 | if (!plan) { 260 | throw new APIError("BAD_REQUEST", { 261 | message: STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND, 262 | }); 263 | } 264 | const subscriptionToUpdate = ctx.body.subscriptionId 265 | ? await ctx.context.adapter.findOne<Subscription>({ 266 | model: "subscription", 267 | where: [ 268 | { 269 | field: "id", 270 | value: ctx.body.subscriptionId, 271 | connector: "OR", 272 | }, 273 | { 274 | field: "stripeSubscriptionId", 275 | value: ctx.body.subscriptionId, 276 | connector: "OR", 277 | }, 278 | ], 279 | }) 280 | : referenceId 281 | ? await ctx.context.adapter.findOne<Subscription>({ 282 | model: "subscription", 283 | where: [{ field: "referenceId", value: referenceId }], 284 | }) 285 | : null; 286 | 287 | if (ctx.body.subscriptionId && !subscriptionToUpdate) { 288 | throw new APIError("BAD_REQUEST", { 289 | message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, 290 | }); 291 | } 292 | 293 | let customerId = 294 | subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId; 295 | 296 | if (!customerId) { 297 | try { 298 | // Try to find existing Stripe customer by email 299 | const existingCustomers = await client.customers.list({ 300 | email: user.email, 301 | limit: 1, 302 | }); 303 | 304 | let stripeCustomer = existingCustomers.data[0]; 305 | 306 | if (!stripeCustomer) { 307 | stripeCustomer = await client.customers.create({ 308 | email: user.email, 309 | name: user.name, 310 | metadata: { 311 | ...ctx.body.metadata, 312 | userId: user.id, 313 | }, 314 | }); 315 | } 316 | 317 | // Update local DB with Stripe customer ID 318 | await ctx.context.adapter.update({ 319 | model: "user", 320 | update: { 321 | stripeCustomerId: stripeCustomer.id, 322 | }, 323 | where: [ 324 | { 325 | field: "id", 326 | value: user.id, 327 | }, 328 | ], 329 | }); 330 | 331 | customerId = stripeCustomer.id; 332 | } catch (e: any) { 333 | ctx.context.logger.error(e); 334 | throw new APIError("BAD_REQUEST", { 335 | message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER, 336 | }); 337 | } 338 | } 339 | 340 | const subscriptions = subscriptionToUpdate 341 | ? [subscriptionToUpdate] 342 | : await ctx.context.adapter.findMany<Subscription>({ 343 | model: "subscription", 344 | where: [ 345 | { 346 | field: "referenceId", 347 | value: ctx.body.referenceId || user.id, 348 | }, 349 | ], 350 | }); 351 | 352 | const activeOrTrialingSubscription = subscriptions.find( 353 | (sub) => sub.status === "active" || sub.status === "trialing", 354 | ); 355 | 356 | const activeSubscriptions = await client.subscriptions 357 | .list({ 358 | customer: customerId, 359 | }) 360 | .then((res) => 361 | res.data.filter( 362 | (sub) => sub.status === "active" || sub.status === "trialing", 363 | ), 364 | ); 365 | 366 | const activeSubscription = activeSubscriptions.find((sub) => { 367 | // If we have a specific subscription to update, match by ID 368 | if ( 369 | subscriptionToUpdate?.stripeSubscriptionId || 370 | ctx.body.subscriptionId 371 | ) { 372 | return ( 373 | sub.id === subscriptionToUpdate?.stripeSubscriptionId || 374 | sub.id === ctx.body.subscriptionId 375 | ); 376 | } 377 | // Only find subscription for the same referenceId to avoid mixing personal and org subscriptions 378 | if (activeOrTrialingSubscription?.stripeSubscriptionId) { 379 | return sub.id === activeOrTrialingSubscription.stripeSubscriptionId; 380 | } 381 | return false; 382 | }); 383 | 384 | // Also find any incomplete subscription that we can reuse 385 | const incompleteSubscription = subscriptions.find( 386 | (sub) => sub.status === "incomplete", 387 | ); 388 | 389 | if ( 390 | activeOrTrialingSubscription && 391 | activeOrTrialingSubscription.status === "active" && 392 | activeOrTrialingSubscription.plan === ctx.body.plan && 393 | activeOrTrialingSubscription.seats === (ctx.body.seats || 1) 394 | ) { 395 | throw new APIError("BAD_REQUEST", { 396 | message: STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN, 397 | }); 398 | } 399 | 400 | if (activeSubscription && customerId) { 401 | // Find the corresponding database subscription for this Stripe subscription 402 | let dbSubscription = await ctx.context.adapter.findOne<Subscription>({ 403 | model: "subscription", 404 | where: [ 405 | { 406 | field: "stripeSubscriptionId", 407 | value: activeSubscription.id, 408 | }, 409 | ], 410 | }); 411 | 412 | // If no database record exists for this Stripe subscription, update the existing one 413 | if (!dbSubscription && activeOrTrialingSubscription) { 414 | await ctx.context.adapter.update<InputSubscription>({ 415 | model: "subscription", 416 | update: { 417 | stripeSubscriptionId: activeSubscription.id, 418 | updatedAt: new Date(), 419 | }, 420 | where: [ 421 | { 422 | field: "id", 423 | value: activeOrTrialingSubscription.id, 424 | }, 425 | ], 426 | }); 427 | dbSubscription = activeOrTrialingSubscription; 428 | } 429 | 430 | // Resolve price ID if using lookup keys 431 | let priceIdToUse: string | undefined = undefined; 432 | if (ctx.body.annual) { 433 | priceIdToUse = plan.annualDiscountPriceId; 434 | if (!priceIdToUse && plan.annualDiscountLookupKey) { 435 | priceIdToUse = await resolvePriceIdFromLookupKey( 436 | client, 437 | plan.annualDiscountLookupKey, 438 | ); 439 | } 440 | } else { 441 | priceIdToUse = plan.priceId; 442 | if (!priceIdToUse && plan.lookupKey) { 443 | priceIdToUse = await resolvePriceIdFromLookupKey( 444 | client, 445 | plan.lookupKey, 446 | ); 447 | } 448 | } 449 | 450 | if (!priceIdToUse) { 451 | throw ctx.error("BAD_REQUEST", { 452 | message: "Price ID not found for the selected plan", 453 | }); 454 | } 455 | 456 | const { url } = await client.billingPortal.sessions 457 | .create({ 458 | customer: customerId, 459 | return_url: getUrl(ctx, ctx.body.returnUrl || "/"), 460 | flow_data: { 461 | type: "subscription_update_confirm", 462 | after_completion: { 463 | type: "redirect", 464 | redirect: { 465 | return_url: getUrl(ctx, ctx.body.returnUrl || "/"), 466 | }, 467 | }, 468 | subscription_update_confirm: { 469 | subscription: activeSubscription.id, 470 | items: [ 471 | { 472 | id: activeSubscription.items.data[0]?.id as string, 473 | quantity: ctx.body.seats || 1, 474 | price: priceIdToUse, 475 | }, 476 | ], 477 | }, 478 | }, 479 | }) 480 | .catch(async (e) => { 481 | throw ctx.error("BAD_REQUEST", { 482 | message: e.message, 483 | code: e.code, 484 | }); 485 | }); 486 | return ctx.json({ 487 | url, 488 | redirect: true, 489 | }); 490 | } 491 | 492 | let subscription: Subscription | undefined = 493 | activeOrTrialingSubscription || incompleteSubscription; 494 | 495 | if (incompleteSubscription && !activeOrTrialingSubscription) { 496 | const updated = await ctx.context.adapter.update<InputSubscription>({ 497 | model: "subscription", 498 | update: { 499 | plan: plan.name.toLowerCase(), 500 | seats: ctx.body.seats || 1, 501 | updatedAt: new Date(), 502 | }, 503 | where: [ 504 | { 505 | field: "id", 506 | value: incompleteSubscription.id, 507 | }, 508 | ], 509 | }); 510 | subscription = (updated as Subscription) || incompleteSubscription; 511 | } 512 | 513 | if (!subscription) { 514 | subscription = await ctx.context.adapter.create< 515 | InputSubscription, 516 | Subscription 517 | >({ 518 | model: "subscription", 519 | data: { 520 | plan: plan.name.toLowerCase(), 521 | stripeCustomerId: customerId, 522 | status: "incomplete", 523 | referenceId, 524 | seats: ctx.body.seats || 1, 525 | }, 526 | }); 527 | } 528 | 529 | if (!subscription) { 530 | ctx.context.logger.error("Subscription ID not found"); 531 | throw new APIError("INTERNAL_SERVER_ERROR"); 532 | } 533 | 534 | const params = await options.subscription?.getCheckoutSessionParams?.( 535 | { 536 | user, 537 | session, 538 | plan, 539 | subscription, 540 | }, 541 | ctx.request, 542 | //@ts-expect-error 543 | ctx, 544 | ); 545 | 546 | const hasEverTrialed = subscriptions.some((s) => { 547 | // Check if user has ever had a trial for any plan (not just the same plan) 548 | // This prevents users from getting multiple trials by switching plans 549 | const hadTrial = 550 | !!(s.trialStart || s.trialEnd) || s.status === "trialing"; 551 | return hadTrial; 552 | }); 553 | 554 | const freeTrial = 555 | !hasEverTrialed && plan.freeTrial 556 | ? { trial_period_days: plan.freeTrial.days } 557 | : undefined; 558 | 559 | let priceIdToUse: string | undefined = undefined; 560 | if (ctx.body.annual) { 561 | priceIdToUse = plan.annualDiscountPriceId; 562 | if (!priceIdToUse && plan.annualDiscountLookupKey) { 563 | priceIdToUse = await resolvePriceIdFromLookupKey( 564 | client, 565 | plan.annualDiscountLookupKey, 566 | ); 567 | } 568 | } else { 569 | priceIdToUse = plan.priceId; 570 | if (!priceIdToUse && plan.lookupKey) { 571 | priceIdToUse = await resolvePriceIdFromLookupKey( 572 | client, 573 | plan.lookupKey, 574 | ); 575 | } 576 | } 577 | const checkoutSession = await client.checkout.sessions 578 | .create( 579 | { 580 | ...(customerId 581 | ? { 582 | customer: customerId, 583 | customer_update: { 584 | name: "auto", 585 | address: "auto", 586 | }, 587 | } 588 | : { 589 | customer_email: session.user.email, 590 | }), 591 | success_url: getUrl( 592 | ctx, 593 | `${ 594 | ctx.context.baseURL 595 | }/subscription/success?callbackURL=${encodeURIComponent( 596 | ctx.body.successUrl, 597 | )}&subscriptionId=${encodeURIComponent(subscription.id)}`, 598 | ), 599 | cancel_url: getUrl(ctx, ctx.body.cancelUrl), 600 | line_items: [ 601 | { 602 | price: priceIdToUse, 603 | quantity: ctx.body.seats || 1, 604 | }, 605 | ], 606 | subscription_data: { 607 | ...freeTrial, 608 | }, 609 | mode: "subscription", 610 | client_reference_id: referenceId, 611 | ...params?.params, 612 | metadata: { 613 | userId: user.id, 614 | subscriptionId: subscription.id, 615 | referenceId, 616 | ...params?.params?.metadata, 617 | }, 618 | }, 619 | params?.options, 620 | ) 621 | .catch(async (e) => { 622 | throw ctx.error("BAD_REQUEST", { 623 | message: e.message, 624 | code: e.code, 625 | }); 626 | }); 627 | return ctx.json({ 628 | ...checkoutSession, 629 | redirect: !ctx.body.disableRedirect, 630 | }); 631 | }, 632 | ), 633 | cancelSubscriptionCallback: createAuthEndpoint( 634 | "/subscription/cancel/callback", 635 | { 636 | method: "GET", 637 | query: z.record(z.string(), z.any()).optional(), 638 | use: [originCheck((ctx) => ctx.query.callbackURL)], 639 | }, 640 | async (ctx) => { 641 | if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) { 642 | throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); 643 | } 644 | const session = await getSessionFromCtx<{ stripeCustomerId: string }>( 645 | ctx, 646 | ); 647 | if (!session) { 648 | throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); 649 | } 650 | const { user } = session; 651 | const { callbackURL, subscriptionId } = ctx.query; 652 | 653 | if (user?.stripeCustomerId) { 654 | try { 655 | const subscription = 656 | await ctx.context.adapter.findOne<Subscription>({ 657 | model: "subscription", 658 | where: [ 659 | { 660 | field: "id", 661 | value: subscriptionId, 662 | }, 663 | ], 664 | }); 665 | if ( 666 | !subscription || 667 | subscription.cancelAtPeriodEnd || 668 | subscription.status === "canceled" 669 | ) { 670 | throw ctx.redirect(getUrl(ctx, callbackURL)); 671 | } 672 | 673 | const stripeSubscription = await client.subscriptions.list({ 674 | customer: user.stripeCustomerId, 675 | status: "active", 676 | }); 677 | const currentSubscription = stripeSubscription.data.find( 678 | (sub) => sub.id === subscription.stripeSubscriptionId, 679 | ); 680 | if (currentSubscription?.cancel_at_period_end === true) { 681 | await ctx.context.adapter.update({ 682 | model: "subscription", 683 | update: { 684 | status: currentSubscription?.status, 685 | cancelAtPeriodEnd: true, 686 | }, 687 | where: [ 688 | { 689 | field: "id", 690 | value: subscription.id, 691 | }, 692 | ], 693 | }); 694 | await options.subscription?.onSubscriptionCancel?.({ 695 | subscription, 696 | cancellationDetails: currentSubscription.cancellation_details, 697 | stripeSubscription: currentSubscription, 698 | event: undefined, 699 | }); 700 | } 701 | } catch (error) { 702 | ctx.context.logger.error( 703 | "Error checking subscription status from Stripe", 704 | error, 705 | ); 706 | } 707 | } 708 | throw ctx.redirect(getUrl(ctx, callbackURL)); 709 | }, 710 | ), 711 | /** 712 | * ### Endpoint 713 | * 714 | * POST `/subscription/cancel` 715 | * 716 | * ### API Methods 717 | * 718 | * **server:** 719 | * `auth.api.cancelSubscription` 720 | * 721 | * **client:** 722 | * `authClient.subscription.cancel` 723 | * 724 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-cancel) 725 | */ 726 | cancelSubscription: createAuthEndpoint( 727 | "/subscription/cancel", 728 | { 729 | method: "POST", 730 | body: z.object({ 731 | referenceId: z 732 | .string() 733 | .meta({ 734 | description: 735 | "Reference id of the subscription to cancel. Eg: '123'", 736 | }) 737 | .optional(), 738 | subscriptionId: z 739 | .string() 740 | .meta({ 741 | description: 742 | "The id of the subscription to cancel. Eg: 'sub_123'", 743 | }) 744 | .optional(), 745 | returnUrl: z.string().meta({ 746 | description: 747 | 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"', 748 | }), 749 | }), 750 | use: [ 751 | sessionMiddleware, 752 | originCheck((ctx) => ctx.body.returnUrl), 753 | referenceMiddleware("cancel-subscription"), 754 | ], 755 | }, 756 | async (ctx) => { 757 | const referenceId = 758 | ctx.body?.referenceId || ctx.context.session.user.id; 759 | const subscription = ctx.body.subscriptionId 760 | ? await ctx.context.adapter.findOne<Subscription>({ 761 | model: "subscription", 762 | where: [ 763 | { 764 | field: "id", 765 | value: ctx.body.subscriptionId, 766 | }, 767 | ], 768 | }) 769 | : await ctx.context.adapter 770 | .findMany<Subscription>({ 771 | model: "subscription", 772 | where: [{ field: "referenceId", value: referenceId }], 773 | }) 774 | .then((subs) => 775 | subs.find( 776 | (sub) => sub.status === "active" || sub.status === "trialing", 777 | ), 778 | ); 779 | 780 | if (!subscription || !subscription.stripeCustomerId) { 781 | throw ctx.error("BAD_REQUEST", { 782 | message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, 783 | }); 784 | } 785 | const activeSubscriptions = await client.subscriptions 786 | .list({ 787 | customer: subscription.stripeCustomerId, 788 | }) 789 | .then((res) => 790 | res.data.filter( 791 | (sub) => sub.status === "active" || sub.status === "trialing", 792 | ), 793 | ); 794 | if (!activeSubscriptions.length) { 795 | /** 796 | * If the subscription is not found, we need to delete the subscription 797 | * from the database. This is a rare case and should not happen. 798 | */ 799 | await ctx.context.adapter.deleteMany({ 800 | model: "subscription", 801 | where: [ 802 | { 803 | field: "referenceId", 804 | value: referenceId, 805 | }, 806 | ], 807 | }); 808 | throw ctx.error("BAD_REQUEST", { 809 | message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, 810 | }); 811 | } 812 | const activeSubscription = activeSubscriptions.find( 813 | (sub) => sub.id === subscription.stripeSubscriptionId, 814 | ); 815 | if (!activeSubscription) { 816 | throw ctx.error("BAD_REQUEST", { 817 | message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, 818 | }); 819 | } 820 | const { url } = await client.billingPortal.sessions 821 | .create({ 822 | customer: subscription.stripeCustomerId, 823 | return_url: getUrl( 824 | ctx, 825 | `${ 826 | ctx.context.baseURL 827 | }/subscription/cancel/callback?callbackURL=${encodeURIComponent( 828 | ctx.body?.returnUrl || "/", 829 | )}&subscriptionId=${encodeURIComponent(subscription.id)}`, 830 | ), 831 | flow_data: { 832 | type: "subscription_cancel", 833 | subscription_cancel: { 834 | subscription: activeSubscription.id, 835 | }, 836 | }, 837 | }) 838 | .catch(async (e) => { 839 | if (e.message.includes("already set to be cancel")) { 840 | /** 841 | * incase we missed the event from stripe, we set it manually 842 | * this is a rare case and should not happen 843 | */ 844 | if (!subscription.cancelAtPeriodEnd) { 845 | await ctx.context.adapter.update({ 846 | model: "subscription", 847 | update: { 848 | cancelAtPeriodEnd: true, 849 | }, 850 | where: [ 851 | { 852 | field: "referenceId", 853 | value: referenceId, 854 | }, 855 | ], 856 | }); 857 | } 858 | } 859 | throw ctx.error("BAD_REQUEST", { 860 | message: e.message, 861 | code: e.code, 862 | }); 863 | }); 864 | return { 865 | url, 866 | redirect: true, 867 | }; 868 | }, 869 | ), 870 | restoreSubscription: createAuthEndpoint( 871 | "/subscription/restore", 872 | { 873 | method: "POST", 874 | body: z.object({ 875 | referenceId: z 876 | .string() 877 | .meta({ 878 | description: 879 | "Reference id of the subscription to restore. Eg: '123'", 880 | }) 881 | .optional(), 882 | subscriptionId: z 883 | .string() 884 | .meta({ 885 | description: 886 | "The id of the subscription to restore. Eg: 'sub_123'", 887 | }) 888 | .optional(), 889 | }), 890 | use: [sessionMiddleware, referenceMiddleware("restore-subscription")], 891 | }, 892 | async (ctx) => { 893 | const referenceId = 894 | ctx.body?.referenceId || ctx.context.session.user.id; 895 | 896 | const subscription = ctx.body.subscriptionId 897 | ? await ctx.context.adapter.findOne<Subscription>({ 898 | model: "subscription", 899 | where: [ 900 | { 901 | field: "id", 902 | value: ctx.body.subscriptionId, 903 | }, 904 | ], 905 | }) 906 | : await ctx.context.adapter 907 | .findMany<Subscription>({ 908 | model: "subscription", 909 | where: [ 910 | { 911 | field: "referenceId", 912 | value: referenceId, 913 | }, 914 | ], 915 | }) 916 | .then((subs) => 917 | subs.find( 918 | (sub) => sub.status === "active" || sub.status === "trialing", 919 | ), 920 | ); 921 | if (!subscription || !subscription.stripeCustomerId) { 922 | throw ctx.error("BAD_REQUEST", { 923 | message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, 924 | }); 925 | } 926 | if ( 927 | subscription.status != "active" && 928 | subscription.status != "trialing" 929 | ) { 930 | throw ctx.error("BAD_REQUEST", { 931 | message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE, 932 | }); 933 | } 934 | if (!subscription.cancelAtPeriodEnd) { 935 | throw ctx.error("BAD_REQUEST", { 936 | message: 937 | STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION, 938 | }); 939 | } 940 | 941 | const activeSubscription = await client.subscriptions 942 | .list({ 943 | customer: subscription.stripeCustomerId, 944 | }) 945 | .then( 946 | (res) => 947 | res.data.filter( 948 | (sub) => sub.status === "active" || sub.status === "trialing", 949 | )[0], 950 | ); 951 | if (!activeSubscription) { 952 | throw ctx.error("BAD_REQUEST", { 953 | message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, 954 | }); 955 | } 956 | 957 | try { 958 | const newSub = await client.subscriptions.update( 959 | activeSubscription.id, 960 | { 961 | cancel_at_period_end: false, 962 | }, 963 | ); 964 | 965 | await ctx.context.adapter.update({ 966 | model: "subscription", 967 | update: { 968 | cancelAtPeriodEnd: false, 969 | updatedAt: new Date(), 970 | }, 971 | where: [ 972 | { 973 | field: "id", 974 | value: subscription.id, 975 | }, 976 | ], 977 | }); 978 | 979 | return ctx.json(newSub); 980 | } catch (error) { 981 | ctx.context.logger.error("Error restoring subscription", error); 982 | throw new APIError("BAD_REQUEST", { 983 | message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER, 984 | }); 985 | } 986 | }, 987 | ), 988 | /** 989 | * ### Endpoint 990 | * 991 | * GET `/subscription/list` 992 | * 993 | * ### API Methods 994 | * 995 | * **server:** 996 | * `auth.api.listActiveSubscriptions` 997 | * 998 | * **client:** 999 | * `authClient.subscription.list` 1000 | * 1001 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-list) 1002 | */ 1003 | listActiveSubscriptions: createAuthEndpoint( 1004 | "/subscription/list", 1005 | { 1006 | method: "GET", 1007 | query: z.optional( 1008 | z.object({ 1009 | referenceId: z 1010 | .string() 1011 | .meta({ 1012 | description: 1013 | "Reference id of the subscription to list. Eg: '123'", 1014 | }) 1015 | .optional(), 1016 | }), 1017 | ), 1018 | use: [sessionMiddleware, referenceMiddleware("list-subscription")], 1019 | }, 1020 | async (ctx) => { 1021 | const subscriptions = await ctx.context.adapter.findMany<Subscription>({ 1022 | model: "subscription", 1023 | where: [ 1024 | { 1025 | field: "referenceId", 1026 | value: ctx.query?.referenceId || ctx.context.session.user.id, 1027 | }, 1028 | ], 1029 | }); 1030 | if (!subscriptions.length) { 1031 | return []; 1032 | } 1033 | const plans = await getPlans(options); 1034 | if (!plans) { 1035 | return []; 1036 | } 1037 | const subs = subscriptions 1038 | .map((sub) => { 1039 | const plan = plans.find( 1040 | (p) => p.name.toLowerCase() === sub.plan.toLowerCase(), 1041 | ); 1042 | return { 1043 | ...sub, 1044 | limits: plan?.limits, 1045 | priceId: plan?.priceId, 1046 | }; 1047 | }) 1048 | .filter((sub) => { 1049 | return sub.status === "active" || sub.status === "trialing"; 1050 | }); 1051 | return ctx.json(subs); 1052 | }, 1053 | ), 1054 | subscriptionSuccess: createAuthEndpoint( 1055 | "/subscription/success", 1056 | { 1057 | method: "GET", 1058 | query: z.record(z.string(), z.any()).optional(), 1059 | use: [originCheck((ctx) => ctx.query.callbackURL)], 1060 | }, 1061 | async (ctx) => { 1062 | if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) { 1063 | throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); 1064 | } 1065 | const session = await getSessionFromCtx<{ stripeCustomerId: string }>( 1066 | ctx, 1067 | ); 1068 | if (!session) { 1069 | throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); 1070 | } 1071 | const { user } = session; 1072 | const { callbackURL, subscriptionId } = ctx.query; 1073 | 1074 | const subscription = await ctx.context.adapter.findOne<Subscription>({ 1075 | model: "subscription", 1076 | where: [ 1077 | { 1078 | field: "id", 1079 | value: subscriptionId, 1080 | }, 1081 | ], 1082 | }); 1083 | 1084 | if ( 1085 | subscription?.status === "active" || 1086 | subscription?.status === "trialing" 1087 | ) { 1088 | return ctx.redirect(getUrl(ctx, callbackURL)); 1089 | } 1090 | const customerId = 1091 | subscription?.stripeCustomerId || user.stripeCustomerId; 1092 | 1093 | if (customerId) { 1094 | try { 1095 | const stripeSubscription = await client.subscriptions 1096 | .list({ 1097 | customer: customerId, 1098 | status: "active", 1099 | }) 1100 | .then((res) => res.data[0]); 1101 | 1102 | if (stripeSubscription) { 1103 | const plan = await getPlanByPriceInfo( 1104 | options, 1105 | stripeSubscription.items.data[0]?.price.id!, 1106 | stripeSubscription.items.data[0]?.price.lookup_key!, 1107 | ); 1108 | 1109 | if (plan && subscription) { 1110 | await ctx.context.adapter.update({ 1111 | model: "subscription", 1112 | update: { 1113 | status: stripeSubscription.status, 1114 | seats: stripeSubscription.items.data[0]?.quantity || 1, 1115 | plan: plan.name.toLowerCase(), 1116 | periodEnd: new Date( 1117 | stripeSubscription.items.data[0]?.current_period_end! * 1118 | 1000, 1119 | ), 1120 | periodStart: new Date( 1121 | stripeSubscription.items.data[0]?.current_period_start! * 1122 | 1000, 1123 | ), 1124 | stripeSubscriptionId: stripeSubscription.id, 1125 | ...(stripeSubscription.trial_start && 1126 | stripeSubscription.trial_end 1127 | ? { 1128 | trialStart: new Date( 1129 | stripeSubscription.trial_start * 1000, 1130 | ), 1131 | trialEnd: new Date( 1132 | stripeSubscription.trial_end * 1000, 1133 | ), 1134 | } 1135 | : {}), 1136 | }, 1137 | where: [ 1138 | { 1139 | field: "id", 1140 | value: subscription.id, 1141 | }, 1142 | ], 1143 | }); 1144 | } 1145 | } 1146 | } catch (error) { 1147 | ctx.context.logger.error( 1148 | "Error fetching subscription from Stripe", 1149 | error, 1150 | ); 1151 | } 1152 | } 1153 | throw ctx.redirect(getUrl(ctx, callbackURL)); 1154 | }, 1155 | ), 1156 | createBillingPortal: createAuthEndpoint( 1157 | "/subscription/billing-portal", 1158 | { 1159 | method: "POST", 1160 | body: z.object({ 1161 | locale: z 1162 | .custom<StripeType.Checkout.Session.Locale>((localization) => { 1163 | return typeof localization === "string"; 1164 | }) 1165 | .optional(), 1166 | referenceId: z.string().optional(), 1167 | returnUrl: z.string().default("/"), 1168 | }), 1169 | use: [ 1170 | sessionMiddleware, 1171 | originCheck((ctx) => ctx.body.returnUrl), 1172 | referenceMiddleware("billing-portal"), 1173 | ], 1174 | }, 1175 | async (ctx) => { 1176 | const { user } = ctx.context.session; 1177 | const referenceId = ctx.body.referenceId || user.id; 1178 | 1179 | let customerId = user.stripeCustomerId; 1180 | 1181 | if (!customerId) { 1182 | const subscription = await ctx.context.adapter 1183 | .findMany<Subscription>({ 1184 | model: "subscription", 1185 | where: [ 1186 | { 1187 | field: "referenceId", 1188 | value: referenceId, 1189 | }, 1190 | ], 1191 | }) 1192 | .then((subs) => 1193 | subs.find( 1194 | (sub) => sub.status === "active" || sub.status === "trialing", 1195 | ), 1196 | ); 1197 | 1198 | customerId = subscription?.stripeCustomerId; 1199 | } 1200 | 1201 | if (!customerId) { 1202 | throw new APIError("BAD_REQUEST", { 1203 | message: "No Stripe customer found for this user", 1204 | }); 1205 | } 1206 | 1207 | try { 1208 | const { url } = await client.billingPortal.sessions.create({ 1209 | locale: ctx.body.locale, 1210 | customer: customerId, 1211 | return_url: getUrl(ctx, ctx.body.returnUrl), 1212 | }); 1213 | 1214 | return ctx.json({ 1215 | url, 1216 | redirect: true, 1217 | }); 1218 | } catch (error: any) { 1219 | ctx.context.logger.error( 1220 | "Error creating billing portal session", 1221 | error, 1222 | ); 1223 | throw new APIError("BAD_REQUEST", { 1224 | message: error.message, 1225 | }); 1226 | } 1227 | }, 1228 | ), 1229 | } as const; 1230 | return { 1231 | id: "stripe", 1232 | endpoints: { 1233 | stripeWebhook: createAuthEndpoint( 1234 | "/stripe/webhook", 1235 | { 1236 | method: "POST", 1237 | metadata: { 1238 | isAction: false, 1239 | }, 1240 | cloneRequest: true, 1241 | //don't parse the body 1242 | disableBody: true, 1243 | }, 1244 | async (ctx) => { 1245 | if (!ctx.request?.body) { 1246 | throw new APIError("INTERNAL_SERVER_ERROR"); 1247 | } 1248 | const buf = await ctx.request.text(); 1249 | const sig = ctx.request.headers.get("stripe-signature") as string; 1250 | const webhookSecret = options.stripeWebhookSecret; 1251 | let event: Stripe.Event; 1252 | try { 1253 | if (!sig || !webhookSecret) { 1254 | throw new APIError("BAD_REQUEST", { 1255 | message: "Stripe webhook secret not found", 1256 | }); 1257 | } 1258 | // Support both Stripe v18 (constructEvent) and v19+ (constructEventAsync) 1259 | if (typeof client.webhooks.constructEventAsync === "function") { 1260 | // Stripe v19+ - use async method 1261 | event = await client.webhooks.constructEventAsync( 1262 | buf, 1263 | sig, 1264 | webhookSecret, 1265 | ); 1266 | } else { 1267 | // Stripe v18 - use sync method 1268 | event = client.webhooks.constructEvent(buf, sig, webhookSecret); 1269 | } 1270 | } catch (err: any) { 1271 | ctx.context.logger.error(`${err.message}`); 1272 | throw new APIError("BAD_REQUEST", { 1273 | message: `Webhook Error: ${err.message}`, 1274 | }); 1275 | } 1276 | if (!event) { 1277 | throw new APIError("BAD_REQUEST", { 1278 | message: "Failed to construct event", 1279 | }); 1280 | } 1281 | try { 1282 | switch (event.type) { 1283 | case "checkout.session.completed": 1284 | await onCheckoutSessionCompleted(ctx, options, event); 1285 | await options.onEvent?.(event); 1286 | break; 1287 | case "customer.subscription.updated": 1288 | await onSubscriptionUpdated(ctx, options, event); 1289 | await options.onEvent?.(event); 1290 | break; 1291 | case "customer.subscription.deleted": 1292 | await onSubscriptionDeleted(ctx, options, event); 1293 | await options.onEvent?.(event); 1294 | break; 1295 | default: 1296 | await options.onEvent?.(event); 1297 | break; 1298 | } 1299 | } catch (e: any) { 1300 | ctx.context.logger.error( 1301 | `Stripe webhook failed. Error: ${e.message}`, 1302 | ); 1303 | throw new APIError("BAD_REQUEST", { 1304 | message: "Webhook error: See server logs for more information.", 1305 | }); 1306 | } 1307 | return ctx.json({ success: true }); 1308 | }, 1309 | ), 1310 | ...((options.subscription?.enabled 1311 | ? subscriptionEndpoints 1312 | : {}) as O["subscription"] extends { 1313 | enabled: boolean; 1314 | } 1315 | ? typeof subscriptionEndpoints 1316 | : {}), 1317 | }, 1318 | init(ctx) { 1319 | return { 1320 | options: { 1321 | databaseHooks: { 1322 | user: { 1323 | create: { 1324 | async after(user, ctx) { 1325 | if (ctx && options.createCustomerOnSignUp) { 1326 | let extraCreateParams: Partial<Stripe.CustomerCreateParams> = 1327 | {}; 1328 | if (options.getCustomerCreateParams) { 1329 | extraCreateParams = await options.getCustomerCreateParams( 1330 | user, 1331 | ctx, 1332 | ); 1333 | } 1334 | 1335 | const params: Stripe.CustomerCreateParams = defu( 1336 | { 1337 | email: user.email, 1338 | name: user.name, 1339 | metadata: { 1340 | userId: user.id, 1341 | }, 1342 | }, 1343 | extraCreateParams, 1344 | ); 1345 | const stripeCustomer = 1346 | await client.customers.create(params); 1347 | await ctx.context.internalAdapter.updateUser(user.id, { 1348 | stripeCustomerId: stripeCustomer.id, 1349 | }); 1350 | await options.onCustomerCreate?.( 1351 | { 1352 | stripeCustomer, 1353 | user: { 1354 | ...user, 1355 | stripeCustomerId: stripeCustomer.id, 1356 | }, 1357 | }, 1358 | ctx, 1359 | ); 1360 | } 1361 | }, 1362 | }, 1363 | update: { 1364 | async after(user, ctx) { 1365 | if (!ctx) return; 1366 | 1367 | try { 1368 | // Cast user to include stripeCustomerId (added by the stripe plugin schema) 1369 | const userWithStripe = user as typeof user & { 1370 | stripeCustomerId?: string; 1371 | }; 1372 | 1373 | // Only proceed if user has a Stripe customer ID 1374 | if (!userWithStripe.stripeCustomerId) return; 1375 | 1376 | // Get the user from the database to check if email actually changed 1377 | // The 'user' parameter here is the freshly updated user 1378 | // We need to check if the Stripe customer's email matches 1379 | const stripeCustomer = await client.customers.retrieve( 1380 | userWithStripe.stripeCustomerId, 1381 | ); 1382 | 1383 | // Check if customer was deleted 1384 | if (stripeCustomer.deleted) { 1385 | ctx.context.logger.warn( 1386 | `Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`, 1387 | ); 1388 | return; 1389 | } 1390 | 1391 | // If Stripe customer email doesn't match the user's current email, update it 1392 | if (stripeCustomer.email !== user.email) { 1393 | await client.customers.update( 1394 | userWithStripe.stripeCustomerId, 1395 | { 1396 | email: user.email, 1397 | }, 1398 | ); 1399 | ctx.context.logger.info( 1400 | `Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`, 1401 | ); 1402 | } 1403 | } catch (e: any) { 1404 | // Ignore errors - this is a best-effort sync 1405 | // Email might have been deleted or Stripe customer might not exist 1406 | ctx.context.logger.error( 1407 | `Failed to sync email to Stripe customer: ${e.message}`, 1408 | e, 1409 | ); 1410 | } 1411 | }, 1412 | }, 1413 | }, 1414 | }, 1415 | }, 1416 | }; 1417 | }, 1418 | schema: getSchema(options), 1419 | $ERROR_CODES: STRIPE_ERROR_CODES, 1420 | } satisfies BetterAuthPlugin; 1421 | }; 1422 | 1423 | export type { Subscription, StripePlan }; 1424 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/admin/admin.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { APIError, getSessionFromCtx } from "../../api"; 3 | import { 4 | createAuthEndpoint, 5 | createAuthMiddleware, 6 | } from "@better-auth/core/api"; 7 | import { type Session } from "../../types"; 8 | import type { BetterAuthPlugin } from "@better-auth/core"; 9 | import type { Where } from "@better-auth/core/db/adapter"; 10 | import { deleteSessionCookie, setSessionCookie } from "../../cookies"; 11 | import { getDate } from "../../utils/date"; 12 | import { getEndpointResponse } from "../../utils/plugin-helper"; 13 | import { mergeSchema, parseUserOutput } from "../../db/schema"; 14 | import { type AccessControl } from "../access"; 15 | import { ADMIN_ERROR_CODES } from "./error-codes"; 16 | import { defaultStatements } from "./access"; 17 | import { hasPermission } from "./has-permission"; 18 | import { BASE_ERROR_CODES } from "@better-auth/core/error"; 19 | import { schema } from "./schema"; 20 | import type { 21 | AdminOptions, 22 | InferAdminRolesFromOption, 23 | SessionWithImpersonatedBy, 24 | UserWithRole, 25 | } from "./types"; 26 | 27 | function parseRoles(roles: string | string[]): string { 28 | return Array.isArray(roles) ? roles.join(",") : roles; 29 | } 30 | 31 | export const admin = <O extends AdminOptions>(options?: O) => { 32 | const opts = { 33 | defaultRole: options?.defaultRole ?? "user", 34 | adminRoles: options?.adminRoles ?? ["admin"], 35 | bannedUserMessage: 36 | options?.bannedUserMessage ?? 37 | "You have been banned from this application. Please contact support if you believe this is an error.", 38 | ...options, 39 | }; 40 | type DefaultStatements = typeof defaultStatements; 41 | type Statements = O["ac"] extends AccessControl<infer S> 42 | ? S 43 | : DefaultStatements; 44 | 45 | type PermissionType = { 46 | [key in keyof Statements]?: Array< 47 | Statements[key] extends readonly unknown[] 48 | ? Statements[key][number] 49 | : never 50 | >; 51 | }; 52 | type PermissionExclusive = 53 | | { 54 | /** 55 | * @deprecated Use `permissions` instead 56 | */ 57 | permission: PermissionType; 58 | permissions?: never; 59 | } 60 | | { 61 | permissions: PermissionType; 62 | permission?: never; 63 | }; 64 | 65 | /** 66 | * Ensures a valid session, if not will throw. 67 | * Will also provide additional types on the user to include role types. 68 | */ 69 | const adminMiddleware = createAuthMiddleware(async (ctx) => { 70 | const session = await getSessionFromCtx(ctx); 71 | if (!session) { 72 | throw new APIError("UNAUTHORIZED"); 73 | } 74 | return { 75 | session, 76 | } as { 77 | session: { 78 | user: UserWithRole; 79 | session: Session; 80 | }; 81 | }; 82 | }); 83 | 84 | return { 85 | id: "admin", 86 | init() { 87 | return { 88 | options: { 89 | databaseHooks: { 90 | user: { 91 | create: { 92 | async before(user) { 93 | return { 94 | data: { 95 | role: options?.defaultRole ?? "user", 96 | ...user, 97 | }, 98 | }; 99 | }, 100 | }, 101 | }, 102 | session: { 103 | create: { 104 | async before(session, ctx) { 105 | if (!ctx) { 106 | return; 107 | } 108 | const user = (await ctx.context.internalAdapter.findUserById( 109 | session.userId, 110 | )) as UserWithRole; 111 | 112 | if (user.banned) { 113 | if ( 114 | user.banExpires && 115 | new Date(user.banExpires).getTime() < Date.now() 116 | ) { 117 | await ctx.context.internalAdapter.updateUser( 118 | session.userId, 119 | { 120 | banned: false, 121 | banReason: null, 122 | banExpires: null, 123 | }, 124 | ); 125 | return; 126 | } 127 | 128 | if ( 129 | ctx && 130 | (ctx.path.startsWith("/callback") || 131 | ctx.path.startsWith("/oauth2/callback")) 132 | ) { 133 | const redirectURI = 134 | ctx.context.options.onAPIError?.errorURL || 135 | `${ctx.context.baseURL}/error`; 136 | throw ctx.redirect( 137 | `${redirectURI}?error=banned&error_description=${opts.bannedUserMessage}`, 138 | ); 139 | } 140 | 141 | throw new APIError("FORBIDDEN", { 142 | message: opts.bannedUserMessage, 143 | code: "BANNED_USER", 144 | }); 145 | } 146 | }, 147 | }, 148 | }, 149 | }, 150 | }, 151 | }; 152 | }, 153 | hooks: { 154 | after: [ 155 | { 156 | matcher(context) { 157 | return context.path === "/list-sessions"; 158 | }, 159 | handler: createAuthMiddleware(async (ctx) => { 160 | const response = 161 | await getEndpointResponse<SessionWithImpersonatedBy[]>(ctx); 162 | 163 | if (!response) { 164 | return; 165 | } 166 | const newJson = response.filter((session) => { 167 | return !session.impersonatedBy; 168 | }); 169 | 170 | return ctx.json(newJson); 171 | }), 172 | }, 173 | ], 174 | }, 175 | endpoints: { 176 | /** 177 | * ### Endpoint 178 | * 179 | * POST `/admin/set-role` 180 | * 181 | * ### API Methods 182 | * 183 | * **server:** 184 | * `auth.api.setRole` 185 | * 186 | * **client:** 187 | * `authClient.admin.setRole` 188 | * 189 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-set-role) 190 | */ 191 | setRole: createAuthEndpoint( 192 | "/admin/set-role", 193 | { 194 | method: "POST", 195 | body: z.object({ 196 | userId: z.coerce.string().meta({ 197 | description: "The user id", 198 | }), 199 | role: z 200 | .union([ 201 | z.string().meta({ 202 | description: "The role to set. `admin` or `user` by default", 203 | }), 204 | z.array( 205 | z.string().meta({ 206 | description: 207 | "The roles to set. `admin` or `user` by default", 208 | }), 209 | ), 210 | ]) 211 | .meta({ 212 | description: 213 | "The role to set, this can be a string or an array of strings. Eg: `admin` or `[admin, user]`", 214 | }), 215 | }), 216 | requireHeaders: true, 217 | use: [adminMiddleware], 218 | metadata: { 219 | openapi: { 220 | operationId: "setRole", 221 | summary: "Set the role of a user", 222 | description: "Set the role of a user", 223 | responses: { 224 | 200: { 225 | description: "User role updated", 226 | content: { 227 | "application/json": { 228 | schema: { 229 | type: "object", 230 | properties: { 231 | user: { 232 | $ref: "#/components/schemas/User", 233 | }, 234 | }, 235 | }, 236 | }, 237 | }, 238 | }, 239 | }, 240 | }, 241 | $Infer: { 242 | body: {} as { 243 | userId: string; 244 | role: 245 | | InferAdminRolesFromOption<O> 246 | | InferAdminRolesFromOption<O>[]; 247 | }, 248 | }, 249 | }, 250 | }, 251 | async (ctx) => { 252 | const canSetRole = hasPermission({ 253 | userId: ctx.context.session.user.id, 254 | role: ctx.context.session.user.role, 255 | options: opts, 256 | permissions: { 257 | user: ["set-role"], 258 | }, 259 | }); 260 | if (!canSetRole) { 261 | throw new APIError("FORBIDDEN", { 262 | message: 263 | ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE, 264 | }); 265 | } 266 | 267 | const updatedUser = await ctx.context.internalAdapter.updateUser( 268 | ctx.body.userId, 269 | { 270 | role: parseRoles(ctx.body.role), 271 | }, 272 | ); 273 | return ctx.json({ 274 | user: updatedUser as UserWithRole, 275 | }); 276 | }, 277 | ), 278 | getUser: createAuthEndpoint( 279 | "/admin/get-user", 280 | { 281 | method: "GET", 282 | query: z.object({ 283 | id: z.string().meta({ 284 | description: "The id of the User", 285 | }), 286 | }), 287 | use: [adminMiddleware], 288 | metadata: { 289 | openapi: { 290 | operationId: "getUser", 291 | summary: "Get an existing user", 292 | description: "Get an existing user", 293 | responses: { 294 | 200: { 295 | description: "User", 296 | content: { 297 | "application/json": { 298 | schema: { 299 | type: "object", 300 | properties: { 301 | user: { 302 | $ref: "#/components/schemas/User", 303 | }, 304 | }, 305 | }, 306 | }, 307 | }, 308 | }, 309 | }, 310 | }, 311 | }, 312 | }, 313 | async (ctx) => { 314 | const { id } = ctx.query; 315 | 316 | const canGetUser = hasPermission({ 317 | userId: ctx.context.session.user.id, 318 | role: ctx.context.session.user.role, 319 | options: opts, 320 | permissions: { 321 | user: ["get"], 322 | }, 323 | }); 324 | 325 | if (!canGetUser) { 326 | throw ctx.error("FORBIDDEN", { 327 | message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_GET_USER, 328 | code: "YOU_ARE_NOT_ALLOWED_TO_GET_USER", 329 | }); 330 | } 331 | 332 | const user = await ctx.context.internalAdapter.findUserById(id); 333 | 334 | if (!user) { 335 | throw new APIError("NOT_FOUND", { 336 | message: BASE_ERROR_CODES.USER_NOT_FOUND, 337 | }); 338 | } 339 | 340 | return parseUserOutput(ctx.context.options, user); 341 | }, 342 | ), 343 | /** 344 | * ### Endpoint 345 | * 346 | * POST `/admin/create-user` 347 | * 348 | * ### API Methods 349 | * 350 | * **server:** 351 | * `auth.api.createUser` 352 | * 353 | * **client:** 354 | * `authClient.admin.createUser` 355 | * 356 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-create-user) 357 | */ 358 | createUser: createAuthEndpoint( 359 | "/admin/create-user", 360 | { 361 | method: "POST", 362 | body: z.object({ 363 | email: z.string().meta({ 364 | description: "The email of the user", 365 | }), 366 | password: z.string().meta({ 367 | description: "The password of the user", 368 | }), 369 | name: z.string().meta({ 370 | description: "The name of the user", 371 | }), 372 | role: z 373 | .union([ 374 | z.string().meta({ 375 | description: "The role of the user", 376 | }), 377 | z.array( 378 | z.string().meta({ 379 | description: "The roles of user", 380 | }), 381 | ), 382 | ]) 383 | .optional() 384 | .meta({ 385 | description: `A string or array of strings representing the roles to apply to the new user. Eg: \"user\"`, 386 | }), 387 | /** 388 | * extra fields for user 389 | */ 390 | data: z.record(z.string(), z.any()).optional().meta({ 391 | description: 392 | "Extra fields for the user. Including custom additional fields.", 393 | }), 394 | }), 395 | metadata: { 396 | openapi: { 397 | operationId: "createUser", 398 | summary: "Create a new user", 399 | description: "Create a new user", 400 | responses: { 401 | 200: { 402 | description: "User created", 403 | content: { 404 | "application/json": { 405 | schema: { 406 | type: "object", 407 | properties: { 408 | user: { 409 | $ref: "#/components/schemas/User", 410 | }, 411 | }, 412 | }, 413 | }, 414 | }, 415 | }, 416 | }, 417 | }, 418 | $Infer: { 419 | body: {} as { 420 | email: string; 421 | password: string; 422 | name: string; 423 | role?: 424 | | InferAdminRolesFromOption<O> 425 | | InferAdminRolesFromOption<O>[]; 426 | data?: Record<string, any>; 427 | }, 428 | }, 429 | }, 430 | }, 431 | async (ctx) => { 432 | const session = await getSessionFromCtx<{ role: string }>(ctx); 433 | if (!session && (ctx.request || ctx.headers)) { 434 | throw ctx.error("UNAUTHORIZED"); 435 | } 436 | if (session) { 437 | const canCreateUser = hasPermission({ 438 | userId: session.user.id, 439 | role: session.user.role, 440 | options: opts, 441 | permissions: { 442 | user: ["create"], 443 | }, 444 | }); 445 | if (!canCreateUser) { 446 | throw new APIError("FORBIDDEN", { 447 | message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS, 448 | }); 449 | } 450 | } 451 | const existUser = await ctx.context.internalAdapter.findUserByEmail( 452 | ctx.body.email, 453 | ); 454 | if (existUser) { 455 | throw new APIError("BAD_REQUEST", { 456 | message: ADMIN_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, 457 | }); 458 | } 459 | const user = 460 | await ctx.context.internalAdapter.createUser<UserWithRole>({ 461 | email: ctx.body.email, 462 | name: ctx.body.name, 463 | role: 464 | (ctx.body.role && parseRoles(ctx.body.role)) ?? 465 | options?.defaultRole ?? 466 | "user", 467 | ...ctx.body.data, 468 | }); 469 | 470 | if (!user) { 471 | throw new APIError("INTERNAL_SERVER_ERROR", { 472 | message: ADMIN_ERROR_CODES.FAILED_TO_CREATE_USER, 473 | }); 474 | } 475 | const hashedPassword = await ctx.context.password.hash( 476 | ctx.body.password, 477 | ); 478 | await ctx.context.internalAdapter.linkAccount({ 479 | accountId: user.id, 480 | providerId: "credential", 481 | password: hashedPassword, 482 | userId: user.id, 483 | }); 484 | return ctx.json({ 485 | user: user as UserWithRole, 486 | }); 487 | }, 488 | ), 489 | /** 490 | * ### Endpoint 491 | * 492 | * POST `/admin/update-user` 493 | * 494 | * ### API Methods 495 | * 496 | * **server:** 497 | * `auth.api.adminUpdateUser` 498 | * 499 | * **client:** 500 | * `authClient.admin.updateUser` 501 | * 502 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-update-user) 503 | */ 504 | adminUpdateUser: createAuthEndpoint( 505 | "/admin/update-user", 506 | { 507 | method: "POST", 508 | body: z.object({ 509 | userId: z.coerce.string().meta({ 510 | description: "The user id", 511 | }), 512 | data: z.record(z.any(), z.any()).meta({ 513 | description: "The user data to update", 514 | }), 515 | }), 516 | use: [adminMiddleware], 517 | metadata: { 518 | openapi: { 519 | operationId: "updateUser", 520 | summary: "Update a user", 521 | description: "Update a user's details", 522 | responses: { 523 | 200: { 524 | description: "User updated", 525 | content: { 526 | "application/json": { 527 | schema: { 528 | type: "object", 529 | properties: { 530 | user: { 531 | $ref: "#/components/schemas/User", 532 | }, 533 | }, 534 | }, 535 | }, 536 | }, 537 | }, 538 | }, 539 | }, 540 | }, 541 | }, 542 | async (ctx) => { 543 | const canUpdateUser = hasPermission({ 544 | userId: ctx.context.session.user.id, 545 | role: ctx.context.session.user.role, 546 | options: opts, 547 | permissions: { 548 | user: ["update"], 549 | }, 550 | }); 551 | if (!canUpdateUser) { 552 | throw ctx.error("FORBIDDEN", { 553 | message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS, 554 | code: "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS", 555 | }); 556 | } 557 | 558 | if (Object.keys(ctx.body.data).length === 0) { 559 | throw new APIError("BAD_REQUEST", { 560 | message: ADMIN_ERROR_CODES.NO_DATA_TO_UPDATE, 561 | }); 562 | } 563 | if (ctx.body.data?.role) { 564 | ctx.body.data.role = parseRoles(ctx.body.data.role); 565 | } 566 | const updatedUser = await ctx.context.internalAdapter.updateUser( 567 | ctx.body.userId, 568 | ctx.body.data, 569 | ); 570 | 571 | return ctx.json(updatedUser as UserWithRole); 572 | }, 573 | ), 574 | listUsers: createAuthEndpoint( 575 | "/admin/list-users", 576 | { 577 | method: "GET", 578 | use: [adminMiddleware], 579 | query: z.object({ 580 | searchValue: z.string().optional().meta({ 581 | description: 'The value to search for. Eg: "some name"', 582 | }), 583 | searchField: z 584 | .enum(["email", "name"]) 585 | .meta({ 586 | description: 587 | 'The field to search in, defaults to email. Can be `email` or `name`. Eg: "name"', 588 | }) 589 | .optional(), 590 | searchOperator: z 591 | .enum(["contains", "starts_with", "ends_with"]) 592 | .meta({ 593 | description: 594 | 'The operator to use for the search. Can be `contains`, `starts_with` or `ends_with`. Eg: "contains"', 595 | }) 596 | .optional(), 597 | limit: z 598 | .string() 599 | .meta({ 600 | description: "The number of users to return", 601 | }) 602 | .or(z.number()) 603 | .optional(), 604 | offset: z 605 | .string() 606 | .meta({ 607 | description: "The offset to start from", 608 | }) 609 | .or(z.number()) 610 | .optional(), 611 | sortBy: z 612 | .string() 613 | .meta({ 614 | description: "The field to sort by", 615 | }) 616 | .optional(), 617 | sortDirection: z 618 | .enum(["asc", "desc"]) 619 | .meta({ 620 | description: "The direction to sort by", 621 | }) 622 | .optional(), 623 | filterField: z 624 | .string() 625 | .meta({ 626 | description: "The field to filter by", 627 | }) 628 | .optional(), 629 | filterValue: z 630 | .string() 631 | .meta({ 632 | description: "The value to filter by", 633 | }) 634 | .or(z.number()) 635 | .or(z.boolean()) 636 | .optional(), 637 | filterOperator: z 638 | .enum(["eq", "ne", "lt", "lte", "gt", "gte", "contains"]) 639 | .meta({ 640 | description: "The operator to use for the filter", 641 | }) 642 | .optional(), 643 | }), 644 | metadata: { 645 | openapi: { 646 | operationId: "listUsers", 647 | summary: "List users", 648 | description: "List users", 649 | responses: { 650 | 200: { 651 | description: "List of users", 652 | content: { 653 | "application/json": { 654 | schema: { 655 | type: "object", 656 | properties: { 657 | users: { 658 | type: "array", 659 | items: { 660 | $ref: "#/components/schemas/User", 661 | }, 662 | }, 663 | total: { 664 | type: "number", 665 | }, 666 | limit: { 667 | type: "number", 668 | }, 669 | offset: { 670 | type: "number", 671 | }, 672 | }, 673 | required: ["users", "total"], 674 | }, 675 | }, 676 | }, 677 | }, 678 | }, 679 | }, 680 | }, 681 | }, 682 | async (ctx) => { 683 | const session = ctx.context.session; 684 | const canListUsers = hasPermission({ 685 | userId: ctx.context.session.user.id, 686 | role: session.user.role, 687 | options: opts, 688 | permissions: { 689 | user: ["list"], 690 | }, 691 | }); 692 | if (!canListUsers) { 693 | throw new APIError("FORBIDDEN", { 694 | message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_USERS, 695 | }); 696 | } 697 | 698 | const where: Where[] = []; 699 | 700 | if (ctx.query?.searchValue) { 701 | where.push({ 702 | field: ctx.query.searchField || "email", 703 | operator: ctx.query.searchOperator || "contains", 704 | value: ctx.query.searchValue, 705 | }); 706 | } 707 | 708 | if (ctx.query?.filterValue) { 709 | where.push({ 710 | field: ctx.query.filterField || "email", 711 | operator: ctx.query.filterOperator || "eq", 712 | value: ctx.query.filterValue, 713 | }); 714 | } 715 | 716 | try { 717 | const users = await ctx.context.internalAdapter.listUsers( 718 | Number(ctx.query?.limit) || undefined, 719 | Number(ctx.query?.offset) || undefined, 720 | ctx.query?.sortBy 721 | ? { 722 | field: ctx.query.sortBy, 723 | direction: ctx.query.sortDirection || "asc", 724 | } 725 | : undefined, 726 | where.length ? where : undefined, 727 | ); 728 | const total = await ctx.context.internalAdapter.countTotalUsers( 729 | where.length ? where : undefined, 730 | ); 731 | return ctx.json({ 732 | users: users as UserWithRole[], 733 | total: total, 734 | limit: Number(ctx.query?.limit) || undefined, 735 | offset: Number(ctx.query?.offset) || undefined, 736 | }); 737 | } catch (e) { 738 | return ctx.json({ 739 | users: [], 740 | total: 0, 741 | }); 742 | } 743 | }, 744 | ), 745 | /** 746 | * ### Endpoint 747 | * 748 | * POST `/admin/list-user-sessions` 749 | * 750 | * ### API Methods 751 | * 752 | * **server:** 753 | * `auth.api.listUserSessions` 754 | * 755 | * **client:** 756 | * `authClient.admin.listUserSessions` 757 | * 758 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-list-user-sessions) 759 | */ 760 | listUserSessions: createAuthEndpoint( 761 | "/admin/list-user-sessions", 762 | { 763 | method: "POST", 764 | use: [adminMiddleware], 765 | body: z.object({ 766 | userId: z.coerce.string().meta({ 767 | description: "The user id", 768 | }), 769 | }), 770 | metadata: { 771 | openapi: { 772 | operationId: "listUserSessions", 773 | summary: "List user sessions", 774 | description: "List user sessions", 775 | responses: { 776 | 200: { 777 | description: "List of user sessions", 778 | content: { 779 | "application/json": { 780 | schema: { 781 | type: "object", 782 | properties: { 783 | sessions: { 784 | type: "array", 785 | items: { 786 | $ref: "#/components/schemas/Session", 787 | }, 788 | }, 789 | }, 790 | }, 791 | }, 792 | }, 793 | }, 794 | }, 795 | }, 796 | }, 797 | }, 798 | async (ctx) => { 799 | const session = ctx.context.session; 800 | const canListSessions = hasPermission({ 801 | userId: ctx.context.session.user.id, 802 | role: session.user.role, 803 | options: opts, 804 | permissions: { 805 | session: ["list"], 806 | }, 807 | }); 808 | if (!canListSessions) { 809 | throw new APIError("FORBIDDEN", { 810 | message: 811 | ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS, 812 | }); 813 | } 814 | 815 | const sessions: SessionWithImpersonatedBy[] = 816 | await ctx.context.internalAdapter.listSessions(ctx.body.userId); 817 | return { 818 | sessions: sessions, 819 | }; 820 | }, 821 | ), 822 | /** 823 | * ### Endpoint 824 | * 825 | * POST `/admin/unban-user` 826 | * 827 | * ### API Methods 828 | * 829 | * **server:** 830 | * `auth.api.unbanUser` 831 | * 832 | * **client:** 833 | * `authClient.admin.unbanUser` 834 | * 835 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-unban-user) 836 | */ 837 | unbanUser: createAuthEndpoint( 838 | "/admin/unban-user", 839 | { 840 | method: "POST", 841 | body: z.object({ 842 | userId: z.coerce.string().meta({ 843 | description: "The user id", 844 | }), 845 | }), 846 | use: [adminMiddleware], 847 | metadata: { 848 | openapi: { 849 | operationId: "unbanUser", 850 | summary: "Unban a user", 851 | description: "Unban a user", 852 | responses: { 853 | 200: { 854 | description: "User unbanned", 855 | content: { 856 | "application/json": { 857 | schema: { 858 | type: "object", 859 | properties: { 860 | user: { 861 | $ref: "#/components/schemas/User", 862 | }, 863 | }, 864 | }, 865 | }, 866 | }, 867 | }, 868 | }, 869 | }, 870 | }, 871 | }, 872 | async (ctx) => { 873 | const session = ctx.context.session; 874 | const canBanUser = hasPermission({ 875 | userId: ctx.context.session.user.id, 876 | role: session.user.role, 877 | options: opts, 878 | permissions: { 879 | user: ["ban"], 880 | }, 881 | }); 882 | if (!canBanUser) { 883 | throw new APIError("FORBIDDEN", { 884 | message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_BAN_USERS, 885 | }); 886 | } 887 | 888 | const user = await ctx.context.internalAdapter.updateUser( 889 | ctx.body.userId, 890 | { 891 | banned: false, 892 | banExpires: null, 893 | banReason: null, 894 | updatedAt: new Date(), 895 | }, 896 | ); 897 | return ctx.json({ 898 | user: user, 899 | }); 900 | }, 901 | ), 902 | /** 903 | * ### Endpoint 904 | * 905 | * POST `/admin/ban-user` 906 | * 907 | * ### API Methods 908 | * 909 | * **server:** 910 | * `auth.api.banUser` 911 | * 912 | * **client:** 913 | * `authClient.admin.banUser` 914 | * 915 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-ban-user) 916 | */ 917 | banUser: createAuthEndpoint( 918 | "/admin/ban-user", 919 | { 920 | method: "POST", 921 | body: z.object({ 922 | userId: z.coerce.string().meta({ 923 | description: "The user id", 924 | }), 925 | /** 926 | * Reason for the ban 927 | */ 928 | banReason: z 929 | .string() 930 | .meta({ 931 | description: "The reason for the ban", 932 | }) 933 | .optional(), 934 | /** 935 | * Number of seconds until the ban expires 936 | */ 937 | banExpiresIn: z 938 | .number() 939 | .meta({ 940 | description: "The number of seconds until the ban expires", 941 | }) 942 | .optional(), 943 | }), 944 | use: [adminMiddleware], 945 | metadata: { 946 | openapi: { 947 | operationId: "banUser", 948 | summary: "Ban a user", 949 | description: "Ban a user", 950 | responses: { 951 | 200: { 952 | description: "User banned", 953 | content: { 954 | "application/json": { 955 | schema: { 956 | type: "object", 957 | properties: { 958 | user: { 959 | $ref: "#/components/schemas/User", 960 | }, 961 | }, 962 | }, 963 | }, 964 | }, 965 | }, 966 | }, 967 | }, 968 | }, 969 | }, 970 | async (ctx) => { 971 | const session = ctx.context.session; 972 | const canBanUser = hasPermission({ 973 | userId: ctx.context.session.user.id, 974 | role: session.user.role, 975 | options: opts, 976 | permissions: { 977 | user: ["ban"], 978 | }, 979 | }); 980 | if (!canBanUser) { 981 | throw new APIError("FORBIDDEN", { 982 | message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_BAN_USERS, 983 | }); 984 | } 985 | 986 | const foundUser = await ctx.context.internalAdapter.findUserById( 987 | ctx.body.userId, 988 | ); 989 | 990 | if (!foundUser) { 991 | throw new APIError("NOT_FOUND", { 992 | message: BASE_ERROR_CODES.USER_NOT_FOUND, 993 | }); 994 | } 995 | 996 | if (ctx.body.userId === ctx.context.session.user.id) { 997 | throw new APIError("BAD_REQUEST", { 998 | message: ADMIN_ERROR_CODES.YOU_CANNOT_BAN_YOURSELF, 999 | }); 1000 | } 1001 | const user = await ctx.context.internalAdapter.updateUser( 1002 | ctx.body.userId, 1003 | { 1004 | banned: true, 1005 | banReason: 1006 | ctx.body.banReason || options?.defaultBanReason || "No reason", 1007 | banExpires: ctx.body.banExpiresIn 1008 | ? getDate(ctx.body.banExpiresIn, "sec") 1009 | : options?.defaultBanExpiresIn 1010 | ? getDate(options.defaultBanExpiresIn, "sec") 1011 | : undefined, 1012 | updatedAt: new Date(), 1013 | }, 1014 | ); 1015 | //revoke all sessions 1016 | await ctx.context.internalAdapter.deleteSessions(ctx.body.userId); 1017 | return ctx.json({ 1018 | user: user, 1019 | }); 1020 | }, 1021 | ), 1022 | /** 1023 | * ### Endpoint 1024 | * 1025 | * POST `/admin/impersonate-user` 1026 | * 1027 | * ### API Methods 1028 | * 1029 | * **server:** 1030 | * `auth.api.impersonateUser` 1031 | * 1032 | * **client:** 1033 | * `authClient.admin.impersonateUser` 1034 | * 1035 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-impersonate-user) 1036 | */ 1037 | impersonateUser: createAuthEndpoint( 1038 | "/admin/impersonate-user", 1039 | { 1040 | method: "POST", 1041 | body: z.object({ 1042 | userId: z.coerce.string().meta({ 1043 | description: "The user id", 1044 | }), 1045 | }), 1046 | use: [adminMiddleware], 1047 | metadata: { 1048 | openapi: { 1049 | operationId: "impersonateUser", 1050 | summary: "Impersonate a user", 1051 | description: "Impersonate a user", 1052 | responses: { 1053 | 200: { 1054 | description: "Impersonation session created", 1055 | content: { 1056 | "application/json": { 1057 | schema: { 1058 | type: "object", 1059 | properties: { 1060 | session: { 1061 | $ref: "#/components/schemas/Session", 1062 | }, 1063 | user: { 1064 | $ref: "#/components/schemas/User", 1065 | }, 1066 | }, 1067 | }, 1068 | }, 1069 | }, 1070 | }, 1071 | }, 1072 | }, 1073 | }, 1074 | }, 1075 | async (ctx) => { 1076 | const canImpersonateUser = hasPermission({ 1077 | userId: ctx.context.session.user.id, 1078 | role: ctx.context.session.user.role, 1079 | options: opts, 1080 | permissions: { 1081 | user: ["impersonate"], 1082 | }, 1083 | }); 1084 | if (!canImpersonateUser) { 1085 | throw new APIError("FORBIDDEN", { 1086 | message: 1087 | ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS, 1088 | }); 1089 | } 1090 | 1091 | const targetUser = await ctx.context.internalAdapter.findUserById( 1092 | ctx.body.userId, 1093 | ); 1094 | 1095 | if (!targetUser) { 1096 | throw new APIError("NOT_FOUND", { 1097 | message: "User not found", 1098 | }); 1099 | } 1100 | 1101 | const session = await ctx.context.internalAdapter.createSession( 1102 | targetUser.id, 1103 | true, 1104 | { 1105 | impersonatedBy: ctx.context.session.user.id, 1106 | expiresAt: options?.impersonationSessionDuration 1107 | ? getDate(options.impersonationSessionDuration, "sec") 1108 | : getDate(60 * 60, "sec"), // 1 hour 1109 | }, 1110 | true, 1111 | ); 1112 | if (!session) { 1113 | throw new APIError("INTERNAL_SERVER_ERROR", { 1114 | message: ADMIN_ERROR_CODES.FAILED_TO_CREATE_USER, 1115 | }); 1116 | } 1117 | const authCookies = ctx.context.authCookies; 1118 | deleteSessionCookie(ctx); 1119 | const dontRememberMeCookie = await ctx.getSignedCookie( 1120 | ctx.context.authCookies.dontRememberToken.name, 1121 | ctx.context.secret, 1122 | ); 1123 | const adminCookieProp = ctx.context.createAuthCookie("admin_session"); 1124 | await ctx.setSignedCookie( 1125 | adminCookieProp.name, 1126 | `${ctx.context.session.session.token}:${ 1127 | dontRememberMeCookie || "" 1128 | }`, 1129 | ctx.context.secret, 1130 | authCookies.sessionToken.options, 1131 | ); 1132 | await setSessionCookie( 1133 | ctx, 1134 | { 1135 | session: session, 1136 | user: targetUser, 1137 | }, 1138 | true, 1139 | ); 1140 | return ctx.json({ 1141 | session: session, 1142 | user: targetUser, 1143 | }); 1144 | }, 1145 | ), 1146 | /** 1147 | * ### Endpoint 1148 | * 1149 | * POST `/admin/stop-impersonating` 1150 | * 1151 | * ### API Methods 1152 | * 1153 | * **server:** 1154 | * `auth.api.stopImpersonating` 1155 | * 1156 | * **client:** 1157 | * `authClient.admin.stopImpersonating` 1158 | * 1159 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-stop-impersonating) 1160 | */ 1161 | stopImpersonating: createAuthEndpoint( 1162 | "/admin/stop-impersonating", 1163 | { 1164 | method: "POST", 1165 | requireHeaders: true, 1166 | }, 1167 | async (ctx) => { 1168 | const session = await getSessionFromCtx< 1169 | {}, 1170 | { 1171 | impersonatedBy: string; 1172 | } 1173 | >(ctx); 1174 | if (!session) { 1175 | throw new APIError("UNAUTHORIZED"); 1176 | } 1177 | if (!session.session.impersonatedBy) { 1178 | throw new APIError("BAD_REQUEST", { 1179 | message: "You are not impersonating anyone", 1180 | }); 1181 | } 1182 | const user = await ctx.context.internalAdapter.findUserById( 1183 | session.session.impersonatedBy, 1184 | ); 1185 | if (!user) { 1186 | throw new APIError("INTERNAL_SERVER_ERROR", { 1187 | message: "Failed to find user", 1188 | }); 1189 | } 1190 | const adminCookieName = 1191 | ctx.context.createAuthCookie("admin_session").name; 1192 | const adminCookie = await ctx.getSignedCookie( 1193 | adminCookieName, 1194 | ctx.context.secret, 1195 | ); 1196 | 1197 | if (!adminCookie) { 1198 | throw new APIError("INTERNAL_SERVER_ERROR", { 1199 | message: "Failed to find admin session", 1200 | }); 1201 | } 1202 | const [adminSessionToken, dontRememberMeCookie] = 1203 | adminCookie?.split(":"); 1204 | const adminSession = await ctx.context.internalAdapter.findSession( 1205 | adminSessionToken!, 1206 | ); 1207 | if (!adminSession || adminSession.session.userId !== user.id) { 1208 | throw new APIError("INTERNAL_SERVER_ERROR", { 1209 | message: "Failed to find admin session", 1210 | }); 1211 | } 1212 | await ctx.context.internalAdapter.deleteSession( 1213 | session.session.token, 1214 | ); 1215 | await setSessionCookie(ctx, adminSession, !!dontRememberMeCookie); 1216 | return ctx.json(adminSession); 1217 | }, 1218 | ), 1219 | /** 1220 | * ### Endpoint 1221 | * 1222 | * POST `/admin/revoke-user-session` 1223 | * 1224 | * ### API Methods 1225 | * 1226 | * **server:** 1227 | * `auth.api.revokeUserSession` 1228 | * 1229 | * **client:** 1230 | * `authClient.admin.revokeUserSession` 1231 | * 1232 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-revoke-user-session) 1233 | */ 1234 | revokeUserSession: createAuthEndpoint( 1235 | "/admin/revoke-user-session", 1236 | { 1237 | method: "POST", 1238 | body: z.object({ 1239 | sessionToken: z.string().meta({ 1240 | description: "The session token", 1241 | }), 1242 | }), 1243 | use: [adminMiddleware], 1244 | metadata: { 1245 | openapi: { 1246 | operationId: "revokeUserSession", 1247 | summary: "Revoke a user session", 1248 | description: "Revoke a user session", 1249 | responses: { 1250 | 200: { 1251 | description: "Session revoked", 1252 | content: { 1253 | "application/json": { 1254 | schema: { 1255 | type: "object", 1256 | properties: { 1257 | success: { 1258 | type: "boolean", 1259 | }, 1260 | }, 1261 | }, 1262 | }, 1263 | }, 1264 | }, 1265 | }, 1266 | }, 1267 | }, 1268 | }, 1269 | async (ctx) => { 1270 | const session = ctx.context.session; 1271 | const canRevokeSession = hasPermission({ 1272 | userId: ctx.context.session.user.id, 1273 | role: session.user.role, 1274 | options: opts, 1275 | permissions: { 1276 | session: ["revoke"], 1277 | }, 1278 | }); 1279 | if (!canRevokeSession) { 1280 | throw new APIError("FORBIDDEN", { 1281 | message: 1282 | ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS, 1283 | }); 1284 | } 1285 | 1286 | await ctx.context.internalAdapter.deleteSession( 1287 | ctx.body.sessionToken, 1288 | ); 1289 | return ctx.json({ 1290 | success: true, 1291 | }); 1292 | }, 1293 | ), 1294 | /** 1295 | * ### Endpoint 1296 | * 1297 | * POST `/admin/revoke-user-sessions` 1298 | * 1299 | * ### API Methods 1300 | * 1301 | * **server:** 1302 | * `auth.api.revokeUserSessions` 1303 | * 1304 | * **client:** 1305 | * `authClient.admin.revokeUserSessions` 1306 | * 1307 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-revoke-user-sessions) 1308 | */ 1309 | revokeUserSessions: createAuthEndpoint( 1310 | "/admin/revoke-user-sessions", 1311 | { 1312 | method: "POST", 1313 | body: z.object({ 1314 | userId: z.coerce.string().meta({ 1315 | description: "The user id", 1316 | }), 1317 | }), 1318 | use: [adminMiddleware], 1319 | metadata: { 1320 | openapi: { 1321 | operationId: "revokeUserSessions", 1322 | summary: "Revoke all user sessions", 1323 | description: "Revoke all user sessions", 1324 | responses: { 1325 | 200: { 1326 | description: "Sessions revoked", 1327 | content: { 1328 | "application/json": { 1329 | schema: { 1330 | type: "object", 1331 | properties: { 1332 | success: { 1333 | type: "boolean", 1334 | }, 1335 | }, 1336 | }, 1337 | }, 1338 | }, 1339 | }, 1340 | }, 1341 | }, 1342 | }, 1343 | }, 1344 | async (ctx) => { 1345 | const session = ctx.context.session; 1346 | const canRevokeSession = hasPermission({ 1347 | userId: ctx.context.session.user.id, 1348 | role: session.user.role, 1349 | options: opts, 1350 | permissions: { 1351 | session: ["revoke"], 1352 | }, 1353 | }); 1354 | if (!canRevokeSession) { 1355 | throw new APIError("FORBIDDEN", { 1356 | message: 1357 | ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS, 1358 | }); 1359 | } 1360 | 1361 | await ctx.context.internalAdapter.deleteSessions(ctx.body.userId); 1362 | return ctx.json({ 1363 | success: true, 1364 | }); 1365 | }, 1366 | ), 1367 | /** 1368 | * ### Endpoint 1369 | * 1370 | * POST `/admin/remove-user` 1371 | * 1372 | * ### API Methods 1373 | * 1374 | * **server:** 1375 | * `auth.api.removeUser` 1376 | * 1377 | * **client:** 1378 | * `authClient.admin.removeUser` 1379 | * 1380 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-remove-user) 1381 | */ 1382 | removeUser: createAuthEndpoint( 1383 | "/admin/remove-user", 1384 | { 1385 | method: "POST", 1386 | body: z.object({ 1387 | userId: z.coerce.string().meta({ 1388 | description: "The user id", 1389 | }), 1390 | }), 1391 | use: [adminMiddleware], 1392 | metadata: { 1393 | openapi: { 1394 | operationId: "removeUser", 1395 | summary: "Remove a user", 1396 | description: 1397 | "Delete a user and all their sessions and accounts. Cannot be undone.", 1398 | responses: { 1399 | 200: { 1400 | description: "User removed", 1401 | content: { 1402 | "application/json": { 1403 | schema: { 1404 | type: "object", 1405 | properties: { 1406 | success: { 1407 | type: "boolean", 1408 | }, 1409 | }, 1410 | }, 1411 | }, 1412 | }, 1413 | }, 1414 | }, 1415 | }, 1416 | }, 1417 | }, 1418 | async (ctx) => { 1419 | const session = ctx.context.session; 1420 | const canDeleteUser = hasPermission({ 1421 | userId: ctx.context.session.user.id, 1422 | role: session.user.role, 1423 | options: opts, 1424 | permissions: { 1425 | user: ["delete"], 1426 | }, 1427 | }); 1428 | if (!canDeleteUser) { 1429 | throw new APIError("FORBIDDEN", { 1430 | message: ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS, 1431 | }); 1432 | } 1433 | 1434 | if (ctx.body.userId === ctx.context.session.user.id) { 1435 | throw new APIError("BAD_REQUEST", { 1436 | message: ADMIN_ERROR_CODES.YOU_CANNOT_REMOVE_YOURSELF, 1437 | }); 1438 | } 1439 | 1440 | const user = await ctx.context.internalAdapter.findUserById( 1441 | ctx.body.userId, 1442 | ); 1443 | 1444 | if (!user) { 1445 | throw new APIError("NOT_FOUND", { 1446 | message: "User not found", 1447 | }); 1448 | } 1449 | 1450 | await ctx.context.internalAdapter.deleteUser(ctx.body.userId); 1451 | return ctx.json({ 1452 | success: true, 1453 | }); 1454 | }, 1455 | ), 1456 | /** 1457 | * ### Endpoint 1458 | * 1459 | * POST `/admin/set-user-password` 1460 | * 1461 | * ### API Methods 1462 | * 1463 | * **server:** 1464 | * `auth.api.setUserPassword` 1465 | * 1466 | * **client:** 1467 | * `authClient.admin.setUserPassword` 1468 | * 1469 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-set-user-password) 1470 | */ 1471 | setUserPassword: createAuthEndpoint( 1472 | "/admin/set-user-password", 1473 | { 1474 | method: "POST", 1475 | body: z.object({ 1476 | newPassword: z 1477 | .string() 1478 | .nonempty("newPassword cannot be empty") 1479 | .meta({ 1480 | description: "The new password", 1481 | }), 1482 | userId: z.coerce.string().nonempty("userId cannot be empty").meta({ 1483 | description: "The user id", 1484 | }), 1485 | }), 1486 | use: [adminMiddleware], 1487 | metadata: { 1488 | openapi: { 1489 | operationId: "setUserPassword", 1490 | summary: "Set a user's password", 1491 | description: "Set a user's password", 1492 | responses: { 1493 | 200: { 1494 | description: "Password set", 1495 | content: { 1496 | "application/json": { 1497 | schema: { 1498 | type: "object", 1499 | properties: { 1500 | status: { 1501 | type: "boolean", 1502 | }, 1503 | }, 1504 | }, 1505 | }, 1506 | }, 1507 | }, 1508 | }, 1509 | }, 1510 | }, 1511 | }, 1512 | async (ctx) => { 1513 | const canSetUserPassword = hasPermission({ 1514 | userId: ctx.context.session.user.id, 1515 | role: ctx.context.session.user.role, 1516 | options: opts, 1517 | permissions: { 1518 | user: ["set-password"], 1519 | }, 1520 | }); 1521 | if (!canSetUserPassword) { 1522 | throw new APIError("FORBIDDEN", { 1523 | message: 1524 | ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD, 1525 | }); 1526 | } 1527 | 1528 | const { newPassword, userId } = ctx.body; 1529 | const minPasswordLength = 1530 | ctx.context.password.config.minPasswordLength; 1531 | if (newPassword.length < minPasswordLength) { 1532 | ctx.context.logger.error("Password is too short"); 1533 | throw new APIError("BAD_REQUEST", { 1534 | message: BASE_ERROR_CODES.PASSWORD_TOO_SHORT, 1535 | }); 1536 | } 1537 | const maxPasswordLength = 1538 | ctx.context.password.config.maxPasswordLength; 1539 | if (newPassword.length > maxPasswordLength) { 1540 | ctx.context.logger.error("Password is too long"); 1541 | throw new APIError("BAD_REQUEST", { 1542 | message: BASE_ERROR_CODES.PASSWORD_TOO_LONG, 1543 | }); 1544 | } 1545 | const hashedPassword = await ctx.context.password.hash(newPassword); 1546 | await ctx.context.internalAdapter.updatePassword( 1547 | userId, 1548 | hashedPassword, 1549 | ); 1550 | return ctx.json({ 1551 | status: true, 1552 | }); 1553 | }, 1554 | ), 1555 | /** 1556 | * ### Endpoint 1557 | * 1558 | * POST `/admin/has-permission` 1559 | * 1560 | * ### API Methods 1561 | * 1562 | * **server:** 1563 | * `auth.api.userHasPermission` 1564 | * 1565 | * **client:** 1566 | * `authClient.admin.hasPermission` 1567 | * 1568 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/admin#api-method-admin-has-permission) 1569 | */ 1570 | userHasPermission: createAuthEndpoint( 1571 | "/admin/has-permission", 1572 | { 1573 | method: "POST", 1574 | body: z 1575 | .object({ 1576 | userId: z.coerce.string().optional().meta({ 1577 | description: `The user id. Eg: "user-id"`, 1578 | }), 1579 | role: z.string().optional().meta({ 1580 | description: `The role to check permission for. Eg: "admin"`, 1581 | }), 1582 | }) 1583 | .and( 1584 | z.union([ 1585 | z.object({ 1586 | permission: z.record(z.string(), z.array(z.string())), 1587 | permissions: z.undefined(), 1588 | }), 1589 | z.object({ 1590 | permission: z.undefined(), 1591 | permissions: z.record(z.string(), z.array(z.string())), 1592 | }), 1593 | ]), 1594 | ), 1595 | metadata: { 1596 | openapi: { 1597 | description: "Check if the user has permission", 1598 | requestBody: { 1599 | content: { 1600 | "application/json": { 1601 | schema: { 1602 | type: "object", 1603 | properties: { 1604 | permission: { 1605 | type: "object", 1606 | description: "The permission to check", 1607 | deprecated: true, 1608 | }, 1609 | permissions: { 1610 | type: "object", 1611 | description: "The permission to check", 1612 | }, 1613 | }, 1614 | required: ["permissions"], 1615 | }, 1616 | }, 1617 | }, 1618 | }, 1619 | responses: { 1620 | "200": { 1621 | description: "Success", 1622 | content: { 1623 | "application/json": { 1624 | schema: { 1625 | type: "object", 1626 | properties: { 1627 | error: { 1628 | type: "string", 1629 | }, 1630 | success: { 1631 | type: "boolean", 1632 | }, 1633 | }, 1634 | required: ["success"], 1635 | }, 1636 | }, 1637 | }, 1638 | }, 1639 | }, 1640 | }, 1641 | $Infer: { 1642 | body: {} as PermissionExclusive & { 1643 | userId?: string; 1644 | role?: InferAdminRolesFromOption<O>; 1645 | }, 1646 | }, 1647 | }, 1648 | }, 1649 | async (ctx) => { 1650 | if (!ctx.body?.permission && !ctx.body?.permissions) { 1651 | throw new APIError("BAD_REQUEST", { 1652 | message: 1653 | "invalid permission check. no permission(s) were passed.", 1654 | }); 1655 | } 1656 | const session = await getSessionFromCtx(ctx); 1657 | 1658 | if (!session && (ctx.request || ctx.headers)) { 1659 | throw new APIError("UNAUTHORIZED"); 1660 | } 1661 | if (!session && !ctx.body.userId && !ctx.body.role) { 1662 | throw new APIError("BAD_REQUEST", { 1663 | message: "user id or role is required", 1664 | }); 1665 | } 1666 | const user = 1667 | session?.user || 1668 | (ctx.body.role 1669 | ? { id: ctx.body.userId || "", role: ctx.body.role } 1670 | : null) || 1671 | ((await ctx.context.internalAdapter.findUserById( 1672 | ctx.body.userId as string, 1673 | )) as { role?: string; id: string }); 1674 | if (!user) { 1675 | throw new APIError("BAD_REQUEST", { 1676 | message: "user not found", 1677 | }); 1678 | } 1679 | const result = hasPermission({ 1680 | userId: user.id, 1681 | role: user.role, 1682 | options: options as AdminOptions, 1683 | permissions: (ctx.body.permissions ?? ctx.body.permission) as any, 1684 | }); 1685 | return ctx.json({ 1686 | error: null, 1687 | success: result, 1688 | }); 1689 | }, 1690 | ), 1691 | }, 1692 | $ERROR_CODES: ADMIN_ERROR_CODES, 1693 | schema: mergeSchema(schema, opts.schema), 1694 | options: options as any, 1695 | } satisfies BetterAuthPlugin; 1696 | }; 1697 | ```