This is page 35 of 71. 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
│ └── stateless
│ ├── .env.example
│ ├── .gitignore
│ ├── next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── src
│ │ ├── app
│ │ │ ├── api
│ │ │ │ ├── auth
│ │ │ │ │ └── [...all]
│ │ │ │ │ └── route.ts
│ │ │ │ └── user
│ │ │ │ └── route.ts
│ │ │ ├── dashboard
│ │ │ │ └── page.tsx
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ └── lib
│ │ ├── auth-client.ts
│ │ └── auth.ts
│ ├── tailwind.config.ts
│ └── tsconfig.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
│ │ │ ├── polar.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
│ ├── 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-declaration
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── demo.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── username.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-custom-schema.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-schema.test.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
│ │ └── 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
│ │ │ │ ├── polar.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
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── expo.test.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.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.base.json
├── tsconfig.json
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/demo/nextjs/app/admin/page.tsx:
--------------------------------------------------------------------------------
```typescript
1 | "use client";
2 |
3 | import { useQuery, useQueryClient } from "@tanstack/react-query";
4 | import { format } from "date-fns";
5 | import {
6 | Calendar as CalendarIcon,
7 | Loader2,
8 | Plus,
9 | RefreshCw,
10 | Trash,
11 | UserCircle,
12 | } from "lucide-react";
13 | import { useRouter } from "next/navigation";
14 | import { useState } from "react";
15 | import { Toaster, toast } from "sonner";
16 | import { Badge } from "@/components/ui/badge";
17 | import { Button } from "@/components/ui/button";
18 | import { Calendar } from "@/components/ui/calendar";
19 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
20 | import {
21 | Dialog,
22 | DialogContent,
23 | DialogHeader,
24 | DialogTitle,
25 | DialogTrigger,
26 | } from "@/components/ui/dialog";
27 | import { Input } from "@/components/ui/input";
28 | import { Label } from "@/components/ui/label";
29 | import {
30 | Popover,
31 | PopoverContent,
32 | PopoverTrigger,
33 | } from "@/components/ui/popover";
34 | import {
35 | Select,
36 | SelectContent,
37 | SelectItem,
38 | SelectTrigger,
39 | SelectValue,
40 | } from "@/components/ui/select";
41 | import {
42 | Table,
43 | TableBody,
44 | TableCell,
45 | TableHead,
46 | TableHeader,
47 | TableRow,
48 | } from "@/components/ui/table";
49 | import { client } from "@/lib/auth-client";
50 | import { cn } from "@/lib/utils";
51 |
52 | type User = {
53 | id: string;
54 | email: string;
55 | name: string;
56 | role: "admin" | "user";
57 | };
58 |
59 | export default function AdminDashboard() {
60 | const queryClient = useQueryClient();
61 | const router = useRouter();
62 | const [isDialogOpen, setIsDialogOpen] = useState(false);
63 | const [newUser, setNewUser] = useState({
64 | email: "",
65 | password: "",
66 | name: "",
67 | role: "user" as const,
68 | });
69 | const [isLoading, setIsLoading] = useState<string | undefined>();
70 | const [isBanDialogOpen, setIsBanDialogOpen] = useState(false);
71 | const [banForm, setBanForm] = useState({
72 | userId: "",
73 | reason: "",
74 | expirationDate: undefined as Date | undefined,
75 | });
76 |
77 | const { data: users, isLoading: isUsersLoading } = useQuery({
78 | queryKey: ["users"],
79 | queryFn: async () => {
80 | const data = await client.admin.listUsers(
81 | {
82 | query: {
83 | limit: 10,
84 | sortBy: "createdAt",
85 | sortDirection: "desc",
86 | },
87 | },
88 | {
89 | throw: true,
90 | },
91 | );
92 | return data?.users || [];
93 | },
94 | });
95 |
96 | const handleCreateUser = async (e: React.FormEvent) => {
97 | e.preventDefault();
98 | setIsLoading("create");
99 | try {
100 | await client.admin.createUser({
101 | email: newUser.email,
102 | password: newUser.password,
103 | name: newUser.name,
104 | role: newUser.role,
105 | });
106 | toast.success("User created successfully");
107 | setNewUser({ email: "", password: "", name: "", role: "user" });
108 | setIsDialogOpen(false);
109 | queryClient.invalidateQueries({
110 | queryKey: ["users"],
111 | });
112 | } catch (error: any) {
113 | toast.error(error.message || "Failed to create user");
114 | } finally {
115 | setIsLoading(undefined);
116 | }
117 | };
118 |
119 | const handleDeleteUser = async (id: string) => {
120 | setIsLoading(`delete-${id}`);
121 | try {
122 | await client.admin.removeUser({ userId: id });
123 | toast.success("User deleted successfully");
124 | queryClient.invalidateQueries({
125 | queryKey: ["users"],
126 | });
127 | } catch (error: any) {
128 | toast.error(error.message || "Failed to delete user");
129 | } finally {
130 | setIsLoading(undefined);
131 | }
132 | };
133 |
134 | const handleRevokeSessions = async (id: string) => {
135 | setIsLoading(`revoke-${id}`);
136 | try {
137 | await client.admin.revokeUserSessions({ userId: id });
138 | toast.success("Sessions revoked for user");
139 | } catch (error: any) {
140 | toast.error(error.message || "Failed to revoke sessions");
141 | } finally {
142 | setIsLoading(undefined);
143 | }
144 | };
145 |
146 | const handleImpersonateUser = async (id: string) => {
147 | setIsLoading(`impersonate-${id}`);
148 | try {
149 | await client.admin.impersonateUser({ userId: id });
150 | toast.success("Impersonated user");
151 | router.push("/dashboard");
152 | } catch (error: any) {
153 | toast.error(error.message || "Failed to impersonate user");
154 | } finally {
155 | setIsLoading(undefined);
156 | }
157 | };
158 |
159 | const handleBanUser = async (e: React.FormEvent) => {
160 | e.preventDefault();
161 | setIsLoading(`ban-${banForm.userId}`);
162 | try {
163 | if (!banForm.expirationDate) {
164 | throw new Error("Expiration date is required");
165 | }
166 | await client.admin.banUser({
167 | userId: banForm.userId,
168 | banReason: banForm.reason,
169 | banExpiresIn: banForm.expirationDate.getTime() - new Date().getTime(),
170 | });
171 | toast.success("User banned successfully");
172 | setIsBanDialogOpen(false);
173 | queryClient.invalidateQueries({
174 | queryKey: ["users"],
175 | });
176 | } catch (error: any) {
177 | toast.error(error.message || "Failed to ban user");
178 | } finally {
179 | setIsLoading(undefined);
180 | }
181 | };
182 |
183 | return (
184 | <div className="container mx-auto p-4 space-y-8">
185 | <Toaster richColors />
186 | <Card>
187 | <CardHeader className="flex flex-row items-center justify-between">
188 | <CardTitle className="text-2xl">Admin Dashboard</CardTitle>
189 | <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
190 | <DialogTrigger asChild>
191 | <Button>
192 | <Plus className="mr-2 h-4 w-4" /> Create User
193 | </Button>
194 | </DialogTrigger>
195 | <DialogContent>
196 | <DialogHeader>
197 | <DialogTitle>Create New User</DialogTitle>
198 | </DialogHeader>
199 | <form onSubmit={handleCreateUser} className="space-y-4">
200 | <div>
201 | <Label htmlFor="email">Email</Label>
202 | <Input
203 | id="email"
204 | type="email"
205 | value={newUser.email}
206 | onChange={(e) =>
207 | setNewUser({ ...newUser, email: e.target.value })
208 | }
209 | required
210 | />
211 | </div>
212 | <div>
213 | <Label htmlFor="password">Password</Label>
214 | <Input
215 | id="password"
216 | type="password"
217 | value={newUser.password}
218 | onChange={(e) =>
219 | setNewUser({ ...newUser, password: e.target.value })
220 | }
221 | required
222 | />
223 | </div>
224 | <div>
225 | <Label htmlFor="name">Name</Label>
226 | <Input
227 | id="name"
228 | value={newUser.name}
229 | onChange={(e) =>
230 | setNewUser({ ...newUser, name: e.target.value })
231 | }
232 | required
233 | />
234 | </div>
235 | <div>
236 | <Label htmlFor="role">Role</Label>
237 | <Select
238 | value={newUser.role}
239 | onValueChange={(value: "admin" | "user") =>
240 | setNewUser({ ...newUser, role: value as "user" })
241 | }
242 | >
243 | <SelectTrigger>
244 | <SelectValue placeholder="Select role" />
245 | </SelectTrigger>
246 | <SelectContent>
247 | <SelectItem value="admin">Admin</SelectItem>
248 | <SelectItem value="user">User</SelectItem>
249 | </SelectContent>
250 | </Select>
251 | </div>
252 | <Button
253 | type="submit"
254 | className="w-full"
255 | disabled={isLoading === "create"}
256 | >
257 | {isLoading === "create" ? (
258 | <>
259 | <Loader2 className="mr-2 h-4 w-4 animate-spin" />
260 | Creating...
261 | </>
262 | ) : (
263 | "Create User"
264 | )}
265 | </Button>
266 | </form>
267 | </DialogContent>
268 | </Dialog>
269 | <Dialog open={isBanDialogOpen} onOpenChange={setIsBanDialogOpen}>
270 | <DialogContent>
271 | <DialogHeader>
272 | <DialogTitle>Ban User</DialogTitle>
273 | </DialogHeader>
274 | <form onSubmit={handleBanUser} className="space-y-4">
275 | <div>
276 | <Label htmlFor="reason">Reason</Label>
277 | <Input
278 | id="reason"
279 | value={banForm.reason}
280 | onChange={(e) =>
281 | setBanForm({ ...banForm, reason: e.target.value })
282 | }
283 | required
284 | />
285 | </div>
286 | <div className="flex flex-col space-y-1.5">
287 | <Label htmlFor="expirationDate">Expiration Date</Label>
288 | <Popover>
289 | <PopoverTrigger asChild>
290 | <Button
291 | id="expirationDate"
292 | variant={"outline"}
293 | className={cn(
294 | "w-full justify-start text-left font-normal",
295 | !banForm.expirationDate && "text-muted-foreground",
296 | )}
297 | >
298 | <CalendarIcon className="mr-2 h-4 w-4" />
299 | {banForm.expirationDate ? (
300 | format(banForm.expirationDate, "PPP")
301 | ) : (
302 | <span>Pick a date</span>
303 | )}
304 | </Button>
305 | </PopoverTrigger>
306 | <PopoverContent className="w-auto p-0">
307 | <Calendar
308 | mode="single"
309 | selected={banForm.expirationDate}
310 | onSelect={(date) =>
311 | setBanForm({ ...banForm, expirationDate: date })
312 | }
313 | initialFocus
314 | />
315 | </PopoverContent>
316 | </Popover>
317 | </div>
318 | <Button
319 | type="submit"
320 | className="w-full"
321 | disabled={isLoading === `ban-${banForm.userId}`}
322 | >
323 | {isLoading === `ban-${banForm.userId}` ? (
324 | <>
325 | <Loader2 className="mr-2 h-4 w-4 animate-spin" />
326 | Banning...
327 | </>
328 | ) : (
329 | "Ban User"
330 | )}
331 | </Button>
332 | </form>
333 | </DialogContent>
334 | </Dialog>
335 | </CardHeader>
336 | <CardContent>
337 | {isUsersLoading ? (
338 | <div className="flex justify-center items-center h-64">
339 | <Loader2 className="h-8 w-8 animate-spin" />
340 | </div>
341 | ) : (
342 | <Table>
343 | <TableHeader>
344 | <TableRow>
345 | <TableHead>Email</TableHead>
346 | <TableHead>Name</TableHead>
347 | <TableHead>Role</TableHead>
348 | <TableHead>Banned</TableHead>
349 | <TableHead>Actions</TableHead>
350 | </TableRow>
351 | </TableHeader>
352 | <TableBody>
353 | {users?.map((user) => (
354 | <TableRow key={user.id}>
355 | <TableCell>{user.email}</TableCell>
356 | <TableCell>{user.name}</TableCell>
357 | <TableCell>{user.role || "user"}</TableCell>
358 | <TableCell>
359 | {user.banned ? (
360 | <Badge variant="destructive">Yes</Badge>
361 | ) : (
362 | <Badge variant="outline">No</Badge>
363 | )}
364 | </TableCell>
365 | <TableCell>
366 | <div className="flex space-x-2">
367 | <Button
368 | variant="destructive"
369 | size="sm"
370 | onClick={() => handleDeleteUser(user.id)}
371 | disabled={isLoading?.startsWith("delete")}
372 | >
373 | {isLoading === `delete-${user.id}` ? (
374 | <Loader2 className="h-4 w-4 animate-spin" />
375 | ) : (
376 | <Trash className="h-4 w-4" />
377 | )}
378 | </Button>
379 | <Button
380 | variant="outline"
381 | size="sm"
382 | onClick={() => handleRevokeSessions(user.id)}
383 | disabled={isLoading?.startsWith("revoke")}
384 | >
385 | {isLoading === `revoke-${user.id}` ? (
386 | <Loader2 className="h-4 w-4 animate-spin" />
387 | ) : (
388 | <RefreshCw className="h-4 w-4" />
389 | )}
390 | </Button>
391 | <Button
392 | variant="secondary"
393 | size="sm"
394 | onClick={() => handleImpersonateUser(user.id)}
395 | disabled={isLoading?.startsWith("impersonate")}
396 | >
397 | {isLoading === `impersonate-${user.id}` ? (
398 | <Loader2 className="h-4 w-4 animate-spin" />
399 | ) : (
400 | <>
401 | <UserCircle className="h-4 w-4 mr-2" />
402 | Impersonate
403 | </>
404 | )}
405 | </Button>
406 | <Button
407 | variant="outline"
408 | size="sm"
409 | onClick={async () => {
410 | setBanForm({
411 | userId: user.id,
412 | reason: "",
413 | expirationDate: undefined,
414 | });
415 | if (user.banned) {
416 | setIsLoading(`ban-${user.id}`);
417 | await client.admin.unbanUser(
418 | {
419 | userId: user.id,
420 | },
421 | {
422 | onError(context) {
423 | toast.error(
424 | context.error.message ||
425 | "Failed to unban user",
426 | );
427 | setIsLoading(undefined);
428 | },
429 | onSuccess() {
430 | queryClient.invalidateQueries({
431 | queryKey: ["users"],
432 | });
433 | toast.success("User unbanned successfully");
434 | },
435 | },
436 | );
437 | queryClient.invalidateQueries({
438 | queryKey: ["users"],
439 | });
440 | } else {
441 | setIsBanDialogOpen(true);
442 | }
443 | }}
444 | disabled={isLoading?.startsWith("ban")}
445 | >
446 | {isLoading === `ban-${user.id}` ? (
447 | <Loader2 className="h-4 w-4 animate-spin" />
448 | ) : user.banned ? (
449 | "Unban"
450 | ) : (
451 | "Ban"
452 | )}
453 | </Button>
454 | </div>
455 | </TableCell>
456 | </TableRow>
457 | ))}
458 | </TableBody>
459 | </Table>
460 | )}
461 | </CardContent>
462 | </Card>
463 | </div>
464 | );
465 | }
466 |
```
--------------------------------------------------------------------------------
/docs/scripts/endpoint-to-doc/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { createAuthEndpoint as BAcreateAuthEndpoint } from "better-auth/api";
2 | import fs from "fs";
3 | import path from "path";
4 | import { z } from "zod";
5 |
6 | playSound("Hero");
7 |
8 | let isUsingSessionMiddleware = false;
9 |
10 | export const {
11 | orgMiddleware,
12 | orgSessionMiddleware,
13 | requestOnlySessionMiddleware,
14 | sessionMiddleware,
15 | originCheck,
16 | adminMiddleware,
17 | referenceMiddleware,
18 | } = {
19 | orgMiddleware: () => {},
20 | referenceMiddleware: (cb: (x: any) => void) => () => {},
21 | orgSessionMiddleware: () => {},
22 | requestOnlySessionMiddleware: () => {},
23 | sessionMiddleware: () => {
24 | isUsingSessionMiddleware = true;
25 | },
26 | originCheck: (cb: (x: any) => void) => () => {},
27 | adminMiddleware: () => {
28 | isUsingSessionMiddleware = true;
29 | },
30 | };
31 |
32 | const file = path.join(process.cwd(), "./scripts/endpoint-to-doc/input.ts");
33 |
34 | function clearImportCache() {
35 | const resolved = new URL(file, import.meta.url).pathname;
36 | delete (globalThis as any).__dynamicImportCache?.[resolved];
37 | delete require.cache[require.resolve(resolved)];
38 | }
39 |
40 | console.log(`Watching: ${file}`);
41 |
42 | fs.watch(file, async () => {
43 | isUsingSessionMiddleware = false;
44 | playSound();
45 | console.log(`Detected file change. Regenerating mdx.`);
46 | const inputCode = fs.readFileSync(file, "utf-8");
47 | if (inputCode.includes(".coerce"))
48 | fs.writeFileSync(file, inputCode.replaceAll(".coerce", ""), "utf-8");
49 | await generateMDX();
50 | playSound("Hero");
51 | });
52 |
53 | async function generateMDX() {
54 | const exports = await import("./input");
55 | clearImportCache();
56 | if (Object.keys(exports).length !== 1)
57 | return console.error(`Please provide at least 1 export.`);
58 | const start = Date.now();
59 | const functionName = Object.keys(exports)[0]! as string;
60 |
61 | const [path, options]: [string, Options] =
62 | //@ts-expect-error
63 | await exports[Object.keys(exports)[0]!];
64 | if (!path || !options) return console.error(`No path or options.`);
65 |
66 | if (options.use) {
67 | options.use.forEach((fn) => fn());
68 | }
69 |
70 | console.log(`function name:`, functionName);
71 |
72 | let jsdoc = generateJSDoc({
73 | path,
74 | functionName,
75 | options,
76 | isServerOnly: options.metadata?.SERVER_ONLY ?? false,
77 | });
78 |
79 | let mdx = `<APIMethod${parseParams(path, options)}>\n\`\`\`ts\n${parseType(
80 | functionName,
81 | options,
82 | )}\n\`\`\`\n</APIMethod>`;
83 |
84 | console.log(`Generated in ${(Date.now() - start).toFixed(2)}ms!`);
85 | fs.writeFileSync(
86 | "./scripts/endpoint-to-doc/output.mdx",
87 | `${APIMethodsHeader}\n\n${mdx}\n\n${JSDocHeader}\n\n${jsdoc}`,
88 | "utf-8",
89 | );
90 | console.log(`Successfully updated \`output.mdx\`!`);
91 | }
92 |
93 | type CreateAuthEndpointProps = Parameters<typeof BAcreateAuthEndpoint>;
94 |
95 | type Options = CreateAuthEndpointProps[1];
96 |
97 | const APIMethodsHeader = `{/* -------------------------------------------------------- */}
98 | {/* APIMethod component */}
99 | {/* -------------------------------------------------------- */}`;
100 |
101 | const JSDocHeader = `{/* -------------------------------------------------------- */}
102 | {/* JSDOC For the endpoint */}
103 | {/* -------------------------------------------------------- */}`;
104 |
105 | export const createAuthEndpoint = async (
106 | ...params: Partial<CreateAuthEndpointProps>
107 | ) => {
108 | const [path, options] = params;
109 | if (!path || !options) return console.error(`No path or options.`);
110 |
111 | return [path, options];
112 | };
113 |
114 | type Body = {
115 | propName: string;
116 | type: string[];
117 | isOptional: boolean;
118 | isServerOnly: boolean;
119 | jsDocComment: string | null;
120 | path: string[];
121 | example: string | undefined;
122 | };
123 |
124 | function parseType(functionName: string, options: Options) {
125 | const body: z.ZodAny = (options.query ?? options.body) as any;
126 |
127 | const parsedBody: Body[] = parseZodShape(body, []);
128 |
129 | // console.log(parsedBody);
130 |
131 | let strBody: string = convertBodyToString(parsedBody);
132 |
133 | return `type ${functionName} = {\n${strBody}}`;
134 | }
135 |
136 | function convertBodyToString(parsedBody: Body[]) {
137 | let strBody: string = ``;
138 | const indentationSpaces = ` `;
139 |
140 | let i = -1;
141 | for (const body of parsedBody) {
142 | i++;
143 | if (body.jsDocComment || body.isServerOnly) {
144 | strBody += `${indentationSpaces.repeat(
145 | 1 + body.path.length,
146 | )}/**\n${indentationSpaces.repeat(1 + body.path.length)} * ${
147 | body.jsDocComment
148 | } ${
149 | body.isServerOnly
150 | ? `\n${indentationSpaces.repeat(1 + body.path.length)} * @serverOnly`
151 | : ""
152 | }\n${indentationSpaces.repeat(1 + body.path.length)} */\n`;
153 | }
154 |
155 | if (body.type[0] === "Object") {
156 | strBody += `${indentationSpaces.repeat(1 + body.path.length)}${
157 | body.propName
158 | }${body.isOptional ? "?" : ""}: {\n`;
159 | } else {
160 | strBody += `${indentationSpaces.repeat(1 + body.path.length)}${
161 | body.propName
162 | }${body.isOptional ? "?" : ""}: ${body.type.join(" | ")}${
163 | typeof body.example !== "undefined" ? ` = ${body.example}` : ""
164 | }\n`;
165 | }
166 |
167 | if (
168 | !parsedBody[i + 1] ||
169 | parsedBody[i + 1].path.length < body.path.length
170 | ) {
171 | let diff = body.path.length - (parsedBody[i + 1]?.path?.length || 0);
172 | for (const index of Array(diff)
173 | .fill(0)
174 | .map((_, i) => i)
175 | .reverse()) {
176 | strBody += `${indentationSpaces.repeat(index + 1)}}\n`;
177 | }
178 | }
179 | }
180 |
181 | return strBody;
182 | }
183 |
184 | function parseZodShape(zod: z.ZodAny, path: string[]) {
185 | const parsedBody: Body[] = [];
186 |
187 | if (!zod || !zod._def) {
188 | return parsedBody;
189 | }
190 |
191 | let isRootOptional = undefined;
192 | let shape = z.object(
193 | { test: z.string({ description: "" }) },
194 | { description: "some descriptiom" },
195 | ).shape;
196 |
197 | //@ts-expect-error
198 | if (zod._def.typeName === "ZodOptional") {
199 | isRootOptional = true;
200 | const eg = z.optional(z.object({}));
201 | const x = zod as never as typeof eg;
202 | //@ts-expect-error
203 | shape = x._def.innerType.shape;
204 | } else {
205 | const eg = z.object({});
206 | const x = zod as never as typeof eg;
207 | //@ts-expect-error
208 | shape = x.shape;
209 | }
210 |
211 | for (const [key, value] of Object.entries(shape)) {
212 | if (!value) continue;
213 | let description = value.description;
214 | let { type, isOptional, defaultValue } = getType(value as any, {
215 | forceOptional: isRootOptional,
216 | });
217 |
218 | let example = description ? description.split(" Eg: ")[1] : undefined;
219 | if (example) description = description?.replace(" Eg: " + example, "");
220 |
221 | let isServerOnly = description
222 | ? description.includes("server-only.")
223 | : false;
224 | if (isServerOnly) description = description?.replace(" server-only. ", "");
225 |
226 | if (!description?.trim().length) description = undefined;
227 |
228 | parsedBody.push({
229 | propName: key,
230 | isOptional: isOptional,
231 | jsDocComment: description ?? null,
232 | path,
233 | isServerOnly,
234 | type,
235 | example: example ?? defaultValue ?? undefined,
236 | });
237 |
238 | if (type[0] === "Object") {
239 | const v = value as never as z.ZodAny;
240 | parsedBody.push(...parseZodShape(v, [...path, key]));
241 | }
242 | }
243 | return parsedBody;
244 | }
245 |
246 | function getType(
247 | value: z.ZodAny,
248 | {
249 | forceNullable,
250 | forceOptional,
251 | forceDefaultValue,
252 | }: {
253 | forceOptional?: boolean;
254 | forceNullable?: boolean;
255 | forceDefaultValue?: string;
256 | } = {},
257 | ): { type: string[]; isOptional: boolean; defaultValue?: string } {
258 | if (!value._def) {
259 | console.error(
260 | `Something went wrong during "getType". value._def isn't defined.`,
261 | );
262 | console.error(`value:`);
263 | console.log(value);
264 | process.exit(1);
265 | }
266 | const _null: string[] = value?.isNullable() ? ["null"] : [];
267 | switch (value._def.typeName as string) {
268 | case "ZodString": {
269 | return {
270 | type: ["string", ..._null],
271 | isOptional: forceOptional ?? value.isOptional(),
272 | defaultValue: forceDefaultValue,
273 | };
274 | }
275 | case "ZodObject": {
276 | return {
277 | type: ["Object", ..._null],
278 | isOptional: forceOptional ?? value.isOptional(),
279 | defaultValue: forceDefaultValue,
280 | };
281 | }
282 | case "ZodBoolean": {
283 | return {
284 | type: ["boolean", ..._null],
285 | isOptional: forceOptional ?? value.isOptional(),
286 | defaultValue: forceDefaultValue,
287 | };
288 | }
289 | case "ZodDate": {
290 | return {
291 | type: ["date", ..._null],
292 | isOptional: forceOptional ?? value.isOptional(),
293 | defaultValue: forceDefaultValue,
294 | };
295 | }
296 | case "ZodEnum": {
297 | const v = value as never as z.ZodEnum<["hello", "world"]>;
298 | const types: string[] = [];
299 | for (const value of v._def.values) {
300 | types.push(JSON.stringify(value));
301 | }
302 | return {
303 | type: types,
304 | isOptional: forceOptional ?? v.isOptional(),
305 | defaultValue: forceDefaultValue,
306 | };
307 | }
308 | case "ZodOptional": {
309 | const v = value as never as z.ZodOptional<z.ZodAny>;
310 | const r = getType(v._def.innerType, {
311 | forceOptional: true,
312 | forceNullable: forceNullable,
313 | });
314 | return {
315 | type: r.type,
316 | isOptional: forceOptional ?? r.isOptional,
317 | defaultValue: forceDefaultValue,
318 | };
319 | }
320 | case "ZodDefault": {
321 | const v = value as never as z.ZodDefault<z.ZodAny>;
322 | const r = getType(v._def.innerType, {
323 | forceOptional: forceOptional,
324 | forceDefaultValue: JSON.stringify(v._def.defaultValue()),
325 | forceNullable: forceNullable,
326 | });
327 | return {
328 | type: r.type,
329 | isOptional: forceOptional ?? r.isOptional,
330 | defaultValue: forceDefaultValue ?? r.defaultValue,
331 | };
332 | }
333 | case "ZodAny": {
334 | return {
335 | type: ["any", ..._null],
336 | isOptional: forceOptional ?? value.isOptional(),
337 | defaultValue: forceDefaultValue,
338 | };
339 | }
340 | case "ZodRecord": {
341 | const v = value as never as z.ZodRecord;
342 | const keys: string[] = getType(v._def.keyType as any).type;
343 | const values: string[] = getType(v._def.valueType as any).type;
344 | return {
345 | type: keys.map((key, i) => `Record<${key}, ${values[i]}>`),
346 | isOptional: forceOptional ?? v.isOptional(),
347 | defaultValue: forceDefaultValue,
348 | };
349 | }
350 | case "ZodNumber": {
351 | return {
352 | type: ["number", ..._null],
353 | isOptional: forceOptional ?? value.isOptional(),
354 | defaultValue: forceDefaultValue,
355 | };
356 | }
357 | case "ZodUnion": {
358 | const v = value as never as z.ZodUnion<[z.ZodAny]>;
359 | const types: string[] = [];
360 | for (const option of v.options) {
361 | const t = getType(option as any).type;
362 | types.push(t.length === 0 ? t[0] : `${t.join(" | ")}`);
363 | }
364 | return {
365 | type: types,
366 | isOptional: forceOptional ?? v.isOptional(),
367 | defaultValue: forceDefaultValue,
368 | };
369 | }
370 | case "ZodNullable": {
371 | const v = value as never as z.ZodNullable<z.ZodAny>;
372 | const r = getType(v._def.innerType, { forceOptional: true });
373 | return {
374 | type: r.type,
375 | isOptional: forceOptional ?? r.isOptional,
376 | defaultValue: forceDefaultValue,
377 | };
378 | }
379 |
380 | case "ZodArray": {
381 | const v = value as never as z.ZodArray<z.ZodAny>;
382 | const types = getType(v._def.type as any);
383 | return {
384 | type: [
385 | `${
386 | types.type.length === 1
387 | ? types.type[0]
388 | : `(${types.type.join(" | ")})`
389 | }[]`,
390 | ..._null,
391 | ],
392 | isOptional: forceOptional ?? v.isOptional(),
393 | defaultValue: forceDefaultValue,
394 | };
395 | }
396 |
397 | default: {
398 | console.error(`Unknown Zod type: ${value._def.typeName}`);
399 | console.log(value._def);
400 | process.exit(1);
401 | }
402 | }
403 | }
404 |
405 | function parseParams(path: string, options: Options): string {
406 | let params: string[] = [];
407 | params.push(`path="${path}"`);
408 | params.push(`method="${options.method}"`);
409 |
410 | if (options.requireHeaders || isUsingSessionMiddleware)
411 | params.push("requireSession");
412 | if (options.metadata?.SERVER_ONLY) params.push("isServerOnly");
413 | if (options.method === "GET" && options.body) params.push("forceAsBody");
414 | if (options.method === "POST" && options.query) params.push("forceAsQuery");
415 |
416 | if (params.length === 2) return " " + params.join(" ");
417 | return "\n " + params.join("\n ") + "\n";
418 | }
419 |
420 | function generateJSDoc({
421 | path,
422 | options,
423 | functionName,
424 | isServerOnly,
425 | }: {
426 | path: string;
427 | options: Options;
428 | functionName: string;
429 | isServerOnly: boolean;
430 | }) {
431 | /**
432 | * ### Endpoint
433 | *
434 | * POST `/organization/set-active`
435 | *
436 | * ### API Methods
437 | *
438 | * **server:**
439 | * `auth.api.setActiveOrganization`
440 | *
441 | * **client:**
442 | * `authClient.organization.setActive`
443 | *
444 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-set-active)
445 | */
446 |
447 | let jsdoc: string[] = [];
448 | jsdoc.push(`### Endpoint`);
449 | jsdoc.push(``);
450 | jsdoc.push(`${options.method} \`${path}\``);
451 | jsdoc.push(``);
452 | jsdoc.push(`### API Methods`);
453 | jsdoc.push(``);
454 | jsdoc.push(`**server:**`);
455 | jsdoc.push(`\`auth.api.${functionName}\``);
456 | jsdoc.push(``);
457 | if (!isServerOnly) {
458 | jsdoc.push(`**client:**`);
459 | jsdoc.push(`\`authClient.${pathToDotNotation(path)}\``);
460 | jsdoc.push(``);
461 | }
462 | jsdoc.push(
463 | `@see [Read our docs to learn more.](https://better-auth.com/docs/plugins/${
464 | path.split("/")[1]
465 | }#api-method${path.replaceAll("/", "-")})`,
466 | );
467 |
468 | return `/**\n * ${jsdoc.join("\n * ")}\n */`;
469 | }
470 |
471 | function pathToDotNotation(input: string): string {
472 | return input
473 | .split("/") // split into segments
474 | .filter(Boolean) // remove empty strings (from leading '/')
475 | .map((segment) =>
476 | segment
477 | .split("-") // split kebab-case
478 | .map((word, i) =>
479 | i === 0
480 | ? word.toLowerCase()
481 | : word.charAt(0).toUpperCase() + word.slice(1),
482 | )
483 | .join(""),
484 | )
485 | .join(".");
486 | }
487 |
488 | function playSound(name: string = "Ping") {
489 | const path = `/System/Library/Sounds/${name}.aiff`;
490 | void Bun.$`afplay ${path}`;
491 | }
492 |
```
--------------------------------------------------------------------------------
/docs/components/ai-chat-modal.tsx:
--------------------------------------------------------------------------------
```typescript
1 | "use client";
2 |
3 | import { betterFetch } from "@better-fetch/fetch";
4 | import { atom } from "jotai";
5 | import { AlertCircle, Bot, Send, User } from "lucide-react";
6 | import { useEffect, useRef, useState } from "react";
7 | import {
8 | Dialog,
9 | DialogContent,
10 | DialogDescription,
11 | DialogHeader,
12 | DialogTitle,
13 | } from "@/components/ui/dialog";
14 | import { Textarea } from "@/components/ui/textarea";
15 | import { cn } from "@/lib/utils";
16 | import { MarkdownRenderer } from "./markdown-renderer";
17 |
18 | interface Message {
19 | id: string;
20 | role: "user" | "assistant";
21 | content: string;
22 | timestamp: Date;
23 | isStreaming?: boolean;
24 | }
25 |
26 | export const aiChatModalAtom = atom(false);
27 |
28 | interface AIChatModalProps {
29 | isOpen: boolean;
30 | onClose: () => void;
31 | }
32 |
33 | export function AIChatModal({ isOpen, onClose }: AIChatModalProps) {
34 | const [messages, setMessages] = useState<Message[]>([]);
35 | const [input, setInput] = useState("");
36 | const [isLoading, setIsLoading] = useState(false);
37 | const [apiError, setApiError] = useState<string | null>(null);
38 | const [sessionId, setSessionId] = useState<string | null>(null);
39 | const [externalUserId] = useState<string>(
40 | () =>
41 | `better-auth-user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
42 | );
43 | const messagesEndRef = useRef<HTMLDivElement>(null);
44 | const abortControllerRef = useRef<AbortController | null>(null);
45 |
46 | const scrollToBottom = () => {
47 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
48 | };
49 |
50 | useEffect(() => {
51 | scrollToBottom();
52 | }, [messages]);
53 |
54 | useEffect(() => {
55 | return () => {
56 | if (abortControllerRef.current) {
57 | abortControllerRef.current.abort();
58 | }
59 | };
60 | }, []);
61 |
62 | useEffect(() => {
63 | if (!isOpen) {
64 | setSessionId(null);
65 | setMessages([]);
66 | setInput("");
67 | setApiError(null);
68 | }
69 | }, [isOpen]);
70 |
71 | const handleSubmit = async (e: React.FormEvent) => {
72 | e.preventDefault();
73 | if (!input.trim() || isLoading) return;
74 |
75 | const userMessage: Message = {
76 | id: Date.now().toString(),
77 | role: "user",
78 | content: input.trim(),
79 | timestamp: new Date(),
80 | };
81 |
82 | setMessages((prev) => [...prev, userMessage]);
83 | setInput("");
84 | setIsLoading(true);
85 | setApiError(null);
86 |
87 | const thinkingMessage: Message = {
88 | id: `thinking-${Date.now()}`,
89 | role: "assistant",
90 | content: "",
91 | timestamp: new Date(),
92 | isStreaming: false,
93 | };
94 |
95 | setMessages((prev) => [...prev, thinkingMessage]);
96 |
97 | abortControllerRef.current = new AbortController();
98 |
99 | try {
100 | const payload = {
101 | question: userMessage.content,
102 | stream: false, // Use non-streaming to get session_id
103 | session_id: sessionId, // Use existing session_id if available
104 | external_user_id: externalUserId, // Use consistent external_user_id for consistency on getting the context right
105 | fetch_existing: false,
106 | };
107 |
108 | const { data, error } = await betterFetch<{
109 | content?: string;
110 | answer?: string;
111 | response?: string;
112 | session_id?: string;
113 | }>("/api/ai-chat", {
114 | method: "POST",
115 | headers: {
116 | "content-type": "application/json",
117 | },
118 | body: JSON.stringify(payload),
119 | signal: abortControllerRef.current.signal,
120 | });
121 |
122 | if (error) {
123 | console.error("API Error Response:", error);
124 | throw new Error(`HTTP ${error.status}: ${error.message}`);
125 | }
126 |
127 | if (data.session_id) {
128 | setSessionId(data.session_id);
129 | }
130 |
131 | let answer = "";
132 | if (data.content) {
133 | answer = data.content;
134 | } else if (data.answer) {
135 | answer = data.answer;
136 | } else if (data.response) {
137 | answer = data.response;
138 | } else if (typeof data === "string") {
139 | answer = data;
140 | } else {
141 | console.error("Unexpected response format:", data);
142 | throw new Error("Unexpected response format from API");
143 | }
144 |
145 | await simulateStreamingEffect(answer, thinkingMessage.id);
146 | } catch (error) {
147 | if (error instanceof Error && error.name === "AbortError") {
148 | console.log("Request was aborted");
149 | return;
150 | }
151 |
152 | console.error("Error calling AI API:", error);
153 |
154 | setMessages((prev) =>
155 | prev.map((msg) =>
156 | msg.id.startsWith("thinking-")
157 | ? {
158 | id: (Date.now() + 1).toString(),
159 | role: "assistant" as const,
160 | content: `I encountered an error while processing your request. Please try again.`,
161 | timestamp: new Date(),
162 | isStreaming: false,
163 | }
164 | : msg,
165 | ),
166 | );
167 |
168 | if (error instanceof Error) {
169 | setApiError(error.message);
170 | }
171 | } finally {
172 | setIsLoading(false);
173 | abortControllerRef.current = null;
174 | }
175 | };
176 |
177 | const simulateStreamingEffect = async (
178 | fullContent: string,
179 | thinkingMessageId: string,
180 | ) => {
181 | const assistantMessageId = (Date.now() + 1).toString();
182 | let displayedContent = "";
183 |
184 | setMessages((prev) =>
185 | prev.map((msg) =>
186 | msg.id === thinkingMessageId
187 | ? {
188 | id: assistantMessageId,
189 | role: "assistant" as const,
190 | content: "",
191 | timestamp: new Date(),
192 | isStreaming: true,
193 | }
194 | : msg,
195 | ),
196 | );
197 |
198 | const words = fullContent.split(" ");
199 | for (let i = 0; i < words.length; i++) {
200 | displayedContent += (i > 0 ? " " : "") + words[i];
201 |
202 | setMessages((prev) =>
203 | prev.map((msg) =>
204 | msg.id === assistantMessageId
205 | ? { ...msg, content: displayedContent }
206 | : msg,
207 | ),
208 | );
209 |
210 | const delay = Math.random() * 50 + 20;
211 | await new Promise((resolve) => setTimeout(resolve, delay));
212 | }
213 |
214 | setMessages((prev) =>
215 | prev.map((msg) =>
216 | msg.id === assistantMessageId ? { ...msg, isStreaming: false } : msg,
217 | ),
218 | );
219 | };
220 |
221 | return (
222 | <Dialog open={isOpen} onOpenChange={onClose}>
223 | <DialogContent className="max-w-4xl border-b h-[80vh] flex flex-col">
224 | <DialogHeader>
225 | <DialogTitle className="flex items-center gap-2">
226 | <Bot className="h-5 w-5 text-primary" />
227 | Ask AI About Better Auth
228 | </DialogTitle>
229 | <DialogDescription>
230 | Ask questions about Better-Auth and get AI-powered answers
231 | {apiError && (
232 | <div className="flex items-center gap-2 mt-2 text-amber-600 dark:text-amber-400">
233 | <AlertCircle className="h-4 w-4" />
234 | <span className="text-xs">
235 | API Error: Something went wrong. Please try again.
236 | </span>
237 | </div>
238 | )}
239 | </DialogDescription>
240 | </DialogHeader>
241 |
242 | <div className="flex-1 flex flex-col min-h-0">
243 | <div
244 | className={cn(
245 | "flex-1 overflow-y-auto space-y-4 p-6",
246 | messages.length === 0 ? "overflow-y-hidden" : "overflow-y-auto",
247 | )}
248 | >
249 | {messages.length === 0 ? (
250 | <div className="flex h-full flex-col items-center justify-center text-center">
251 | <div className="mb-6">
252 | <div className="w-16 h-16 mx-auto bg-transparent border border-input/70 border-dashed rounded-none flex items-center justify-center mb-4">
253 | <Bot className="h-8 w-8 text-primary" />
254 | </div>
255 | </div>
256 |
257 | <div className="mb-8 max-w-md">
258 | <h3 className="text-xl font-semibold text-foreground mb-2">
259 | Ask About Better Auth
260 | </h3>
261 | <p className="text-muted-foreground text-sm leading-relaxed">
262 | I'm here to help you with Better Auth questions, setup
263 | guides, and implementation tips. Ask me anything!
264 | </p>
265 | </div>
266 |
267 | <div className="w-full max-w-lg">
268 | <p className="text-sm font-medium text-foreground mb-4">
269 | Try asking:
270 | </p>
271 | <div className="space-y-3">
272 | {[
273 | "How do I set up SSO with Google?",
274 | "How to integrate Better Auth with NextJs?",
275 | "How to setup Two Factor Authentication?",
276 | ].map((question, index) => (
277 | <button
278 | key={index}
279 | onClick={() => setInput(question)}
280 | className="w-full text-left p-3 rounded-none border border-border/50 hover:border-primary/50 hover:bg-primary/5 transition-all duration-200 group"
281 | >
282 | <div className="flex items-center gap-3">
283 | <div className="w-6 h-6 rounded-none bg-transparent border border-input/70 border-dashed flex items-center justify-center group-hover:bg-primary/20 transition-colors">
284 | <span className="text-xs text-primary font-medium">
285 | {index + 1}
286 | </span>
287 | </div>
288 | <span className="text-sm text-foreground group-hover:text-primary transition-colors">
289 | {question}
290 | </span>
291 | </div>
292 | </button>
293 | ))}
294 | </div>
295 | </div>
296 | </div>
297 | ) : (
298 | messages.map((message) => (
299 | <div
300 | key={message.id}
301 | className={cn(
302 | "flex gap-3",
303 | message.role === "user" ? "justify-end" : "justify-start",
304 | )}
305 | >
306 | {message.role === "assistant" && (
307 | <div className="flex-shrink-0">
308 | <div className="w-8 h-8 rounded-full bg-transparent border border-input/70 border-dashed flex items-center justify-center">
309 | <Bot className="h-4 w-4 text-primary" />
310 | </div>
311 | </div>
312 | )}
313 | <div
314 | className={cn(
315 | "max-w-[80%] rounded-xl px-4 py-3 shadow-sm",
316 | message.role === "user"
317 | ? "bg-primary text-primary-foreground"
318 | : "bg-background border border-border/50",
319 | )}
320 | >
321 | {message.role === "assistant" ? (
322 | <div className="w-full">
323 | {message.id.startsWith("thinking-") ? (
324 | <div className="flex items-center gap-2 text-sm text-muted-foreground">
325 | <div className="flex space-x-1">
326 | <div className="w-1 h-1 bg-primary rounded-full animate-bounce [animation-delay:-0.3s]"></div>
327 | <div className="w-1 h-1 bg-primary rounded-full animate-bounce [animation-delay:-0.15s]"></div>
328 | <div className="w-1 h-1 bg-primary rounded-full animate-bounce"></div>
329 | </div>
330 | <span>Thinking...</span>
331 | </div>
332 | ) : (
333 | <>
334 | <MarkdownRenderer content={message.content} />
335 | {message.isStreaming && (
336 | <div className="inline-block w-2 h-4 bg-primary streaming-cursor ml-1" />
337 | )}
338 | </>
339 | )}
340 | </div>
341 | ) : (
342 | <p className="text-sm">{message.content}</p>
343 | )}
344 | </div>
345 | {message.role === "user" && (
346 | <div className="flex-shrink-0">
347 | <div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
348 | <User className="h-4 w-4" />
349 | </div>
350 | </div>
351 | )}
352 | </div>
353 | ))
354 | )}
355 | <div ref={messagesEndRef} />
356 | </div>
357 |
358 | <div className="border-t px-0 bg-background/50 backdrop-blur-sm p-4">
359 | <div className="relative max-w-4xl mx-auto">
360 | <div
361 | className={cn(
362 | "relative flex flex-col border-input rounded-lg transition-all duration-200 w-full text-left",
363 | "ring-1 ring-border/20 bg-muted/30 border-input border-1 backdrop-blur-sm",
364 | "focus-within:ring-primary/30 focus-within:bg-muted/[35%]",
365 | )}
366 | >
367 | <div className="overflow-y-auto max-h-[200px]">
368 | <Textarea
369 | value={input}
370 | onChange={(e) => setInput(e.target.value)}
371 | placeholder="Ask a question about Better-Auth..."
372 | className="w-full rounded-none rounded-b-none px-4 py-3 h-[70px] bg-transparent border-none text-foreground placeholder:text-muted-foreground resize-none focus-visible:ring-0 leading-[1.2] min-h-[52px] max-h-32"
373 | disabled={isLoading}
374 | onKeyDown={(e) => {
375 | if (e.key === "Enter" && !e.shiftKey) {
376 | e.preventDefault();
377 | void handleSubmit(e);
378 | }
379 | }}
380 | />
381 | </div>
382 |
383 | <div className="h-12 bg-muted/20 rounded-b-xl flex items-center justify-end px-3">
384 | <button
385 | type="submit"
386 | onClick={(e) => {
387 | e.preventDefault();
388 | void handleSubmit(e);
389 | }}
390 | disabled={!input.trim() || isLoading}
391 | className={cn(
392 | "rounded-lg p-2 transition-all duration-200",
393 | input.trim() && !isLoading
394 | ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-md"
395 | : "bg-muted/50 text-muted-foreground cursor-not-allowed",
396 | )}
397 | >
398 | {isLoading ? (
399 | <div className="w-4 h-4 border-2 border-current/30 border-t-current rounded-full animate-spin" />
400 | ) : (
401 | <Send className="h-4 w-4" />
402 | )}
403 | </button>
404 | </div>
405 | </div>
406 | </div>
407 |
408 | <div className="mt-3 text-center">
409 | <p className="text-xs text-muted-foreground">
410 | Press{" "}
411 | <kbd className="px-1.5 py-0.5 text-xs bg-muted rounded">
412 | Enter
413 | </kbd>{" "}
414 | to send,{" "}
415 | <kbd className="px-1.5 py-0.5 text-xs bg-muted rounded">
416 | Shift+Enter
417 | </kbd>{" "}
418 | for new line
419 | </p>
420 | </div>
421 | </div>
422 | </div>
423 | </DialogContent>
424 | </Dialog>
425 | );
426 | }
427 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/api-key/routes/create-api-key.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { AuthContext } from "@better-auth/core";
2 | import { createAuthEndpoint } from "@better-auth/core/api";
3 | import * as z from "zod";
4 | import { APIError, getSessionFromCtx } from "../../../api";
5 | import { getDate } from "../../../utils/date";
6 | import { safeJSONParse } from "../../../utils/json";
7 | import { API_KEY_TABLE_NAME, ERROR_CODES } from "..";
8 | import { defaultKeyHasher } from "../";
9 | import { apiKeySchema } from "../schema";
10 | import type { ApiKey } from "../types";
11 | import type { PredefinedApiKeyOptions } from ".";
12 |
13 | export function createApiKey({
14 | keyGenerator,
15 | opts,
16 | schema,
17 | deleteAllExpiredApiKeys,
18 | }: {
19 | keyGenerator: (options: {
20 | length: number;
21 | prefix: string | undefined;
22 | }) => Promise<string> | string;
23 | opts: PredefinedApiKeyOptions;
24 | schema: ReturnType<typeof apiKeySchema>;
25 | deleteAllExpiredApiKeys(
26 | ctx: AuthContext,
27 | byPassLastCheckTime?: boolean,
28 | ): void;
29 | }) {
30 | return createAuthEndpoint(
31 | "/api-key/create",
32 | {
33 | method: "POST",
34 | body: z.object({
35 | name: z
36 | .string()
37 | .meta({ description: "Name of the Api Key" })
38 | .optional(),
39 | expiresIn: z
40 | .number()
41 | .meta({
42 | description: "Expiration time of the Api Key in seconds",
43 | })
44 | .min(1)
45 | .optional()
46 | .nullable()
47 | .default(null),
48 |
49 | userId: z.coerce
50 | .string()
51 | .meta({
52 | description:
53 | 'User Id of the user that the Api Key belongs to. server-only. Eg: "user-id"',
54 | })
55 | .optional(),
56 | prefix: z
57 | .string()
58 | .meta({ description: "Prefix of the Api Key" })
59 | .regex(/^[a-zA-Z0-9_-]+$/, {
60 | message:
61 | "Invalid prefix format, must be alphanumeric and contain only underscores and hyphens.",
62 | })
63 | .optional(),
64 | remaining: z
65 | .number()
66 | .meta({
67 | description: "Remaining number of requests. Server side only",
68 | })
69 | .min(0)
70 | .optional()
71 | .nullable()
72 | .default(null),
73 | metadata: z.any().optional(),
74 | refillAmount: z
75 | .number()
76 | .meta({
77 | description:
78 | "Amount to refill the remaining count of the Api Key. server-only. Eg: 100",
79 | })
80 | .min(1)
81 | .optional(),
82 | refillInterval: z
83 | .number()
84 | .meta({
85 | description:
86 | "Interval to refill the Api Key in milliseconds. server-only. Eg: 1000",
87 | })
88 | .optional(),
89 | rateLimitTimeWindow: z
90 | .number()
91 | .meta({
92 | description:
93 | "The duration in milliseconds where each request is counted. Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. server-only. Eg: 1000",
94 | })
95 | .optional(),
96 | rateLimitMax: z
97 | .number()
98 | .meta({
99 | description:
100 | "Maximum amount of requests allowed within a window. Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. server-only. Eg: 100",
101 | })
102 | .optional(),
103 | rateLimitEnabled: z
104 | .boolean()
105 | .meta({
106 | description:
107 | "Whether the key has rate limiting enabled. server-only. Eg: true",
108 | })
109 | .optional(),
110 | permissions: z
111 | .record(z.string(), z.array(z.string()))
112 | .meta({
113 | description: "Permissions of the Api Key.",
114 | })
115 | .optional(),
116 | }),
117 | metadata: {
118 | openapi: {
119 | description: "Create a new API key for a user",
120 | responses: {
121 | "200": {
122 | description: "API key created successfully",
123 | content: {
124 | "application/json": {
125 | schema: {
126 | type: "object",
127 | properties: {
128 | id: {
129 | type: "string",
130 | description: "Unique identifier of the API key",
131 | },
132 | createdAt: {
133 | type: "string",
134 | format: "date-time",
135 | description: "Creation timestamp",
136 | },
137 | updatedAt: {
138 | type: "string",
139 | format: "date-time",
140 | description: "Last update timestamp",
141 | },
142 | name: {
143 | type: "string",
144 | nullable: true,
145 | description: "Name of the API key",
146 | },
147 | prefix: {
148 | type: "string",
149 | nullable: true,
150 | description: "Prefix of the API key",
151 | },
152 | start: {
153 | type: "string",
154 | nullable: true,
155 | description:
156 | "Starting characters of the key (if configured)",
157 | },
158 | key: {
159 | type: "string",
160 | description:
161 | "The full API key (only returned on creation)",
162 | },
163 | enabled: {
164 | type: "boolean",
165 | description: "Whether the key is enabled",
166 | },
167 | expiresAt: {
168 | type: "string",
169 | format: "date-time",
170 | nullable: true,
171 | description: "Expiration timestamp",
172 | },
173 | userId: {
174 | type: "string",
175 | description: "ID of the user owning the key",
176 | },
177 | lastRefillAt: {
178 | type: "string",
179 | format: "date-time",
180 | nullable: true,
181 | description: "Last refill timestamp",
182 | },
183 | lastRequest: {
184 | type: "string",
185 | format: "date-time",
186 | nullable: true,
187 | description: "Last request timestamp",
188 | },
189 | metadata: {
190 | type: "object",
191 | nullable: true,
192 | additionalProperties: true,
193 | description: "Metadata associated with the key",
194 | },
195 | rateLimitMax: {
196 | type: "number",
197 | nullable: true,
198 | description: "Maximum requests in time window",
199 | },
200 | rateLimitTimeWindow: {
201 | type: "number",
202 | nullable: true,
203 | description: "Rate limit time window in milliseconds",
204 | },
205 | remaining: {
206 | type: "number",
207 | nullable: true,
208 | description: "Remaining requests",
209 | },
210 | refillAmount: {
211 | type: "number",
212 | nullable: true,
213 | description: "Amount to refill",
214 | },
215 | refillInterval: {
216 | type: "number",
217 | nullable: true,
218 | description: "Refill interval in milliseconds",
219 | },
220 | rateLimitEnabled: {
221 | type: "boolean",
222 | description: "Whether rate limiting is enabled",
223 | },
224 | requestCount: {
225 | type: "number",
226 | description: "Current request count in window",
227 | },
228 | permissions: {
229 | type: "object",
230 | nullable: true,
231 | additionalProperties: {
232 | type: "array",
233 | items: { type: "string" },
234 | },
235 | description: "Permissions associated with the key",
236 | },
237 | },
238 | required: [
239 | "id",
240 | "createdAt",
241 | "updatedAt",
242 | "key",
243 | "enabled",
244 | "userId",
245 | "rateLimitEnabled",
246 | "requestCount",
247 | ],
248 | },
249 | },
250 | },
251 | },
252 | },
253 | },
254 | },
255 | },
256 | async (ctx) => {
257 | const {
258 | name,
259 | expiresIn,
260 | prefix,
261 | remaining,
262 | metadata,
263 | refillAmount,
264 | refillInterval,
265 | permissions,
266 | rateLimitMax,
267 | rateLimitTimeWindow,
268 | rateLimitEnabled,
269 | } = ctx.body;
270 |
271 | const session = await getSessionFromCtx(ctx);
272 | const authRequired = ctx.request || ctx.headers;
273 | const user =
274 | authRequired && !session
275 | ? null
276 | : session?.user || { id: ctx.body.userId };
277 |
278 | if (!user?.id) {
279 | throw new APIError("UNAUTHORIZED", {
280 | message: ERROR_CODES.UNAUTHORIZED_SESSION,
281 | });
282 | }
283 |
284 | if (session && ctx.body.userId && session?.user.id !== ctx.body.userId) {
285 | throw new APIError("UNAUTHORIZED", {
286 | message: ERROR_CODES.UNAUTHORIZED_SESSION,
287 | });
288 | }
289 |
290 | if (authRequired) {
291 | // if this endpoint was being called from the client,
292 | // we must make sure they can't use server-only properties.
293 | if (
294 | refillAmount !== undefined ||
295 | refillInterval !== undefined ||
296 | rateLimitMax !== undefined ||
297 | rateLimitTimeWindow !== undefined ||
298 | rateLimitEnabled !== undefined ||
299 | permissions !== undefined ||
300 | remaining !== null
301 | ) {
302 | throw new APIError("BAD_REQUEST", {
303 | message: ERROR_CODES.SERVER_ONLY_PROPERTY,
304 | });
305 | }
306 | }
307 |
308 | // if metadata is defined, than check that it's an object.
309 | if (metadata) {
310 | if (opts.enableMetadata === false) {
311 | throw new APIError("BAD_REQUEST", {
312 | message: ERROR_CODES.METADATA_DISABLED,
313 | });
314 | }
315 | if (typeof metadata !== "object") {
316 | throw new APIError("BAD_REQUEST", {
317 | message: ERROR_CODES.INVALID_METADATA_TYPE,
318 | });
319 | }
320 | }
321 |
322 | // make sure that if they pass a refill amount, they also pass a refill interval
323 | if (refillAmount && !refillInterval) {
324 | throw new APIError("BAD_REQUEST", {
325 | message: ERROR_CODES.REFILL_AMOUNT_AND_INTERVAL_REQUIRED,
326 | });
327 | }
328 | // make sure that if they pass a refill interval, they also pass a refill amount
329 | if (refillInterval && !refillAmount) {
330 | throw new APIError("BAD_REQUEST", {
331 | message: ERROR_CODES.REFILL_INTERVAL_AND_AMOUNT_REQUIRED,
332 | });
333 | }
334 |
335 | if (expiresIn) {
336 | if (opts.keyExpiration.disableCustomExpiresTime === true) {
337 | throw new APIError("BAD_REQUEST", {
338 | message: ERROR_CODES.KEY_DISABLED_EXPIRATION,
339 | });
340 | }
341 |
342 | const expiresIn_in_days = expiresIn / (60 * 60 * 24);
343 |
344 | if (opts.keyExpiration.minExpiresIn > expiresIn_in_days) {
345 | throw new APIError("BAD_REQUEST", {
346 | message: ERROR_CODES.EXPIRES_IN_IS_TOO_SMALL,
347 | });
348 | } else if (opts.keyExpiration.maxExpiresIn < expiresIn_in_days) {
349 | throw new APIError("BAD_REQUEST", {
350 | message: ERROR_CODES.EXPIRES_IN_IS_TOO_LARGE,
351 | });
352 | }
353 | }
354 | if (prefix) {
355 | if (prefix.length < opts.minimumPrefixLength) {
356 | throw new APIError("BAD_REQUEST", {
357 | message: ERROR_CODES.INVALID_PREFIX_LENGTH,
358 | });
359 | }
360 | if (prefix.length > opts.maximumPrefixLength) {
361 | throw new APIError("BAD_REQUEST", {
362 | message: ERROR_CODES.INVALID_PREFIX_LENGTH,
363 | });
364 | }
365 | }
366 |
367 | if (name) {
368 | if (name.length < opts.minimumNameLength) {
369 | throw new APIError("BAD_REQUEST", {
370 | message: ERROR_CODES.INVALID_NAME_LENGTH,
371 | });
372 | }
373 | if (name.length > opts.maximumNameLength) {
374 | throw new APIError("BAD_REQUEST", {
375 | message: ERROR_CODES.INVALID_NAME_LENGTH,
376 | });
377 | }
378 | } else if (opts.requireName) {
379 | throw new APIError("BAD_REQUEST", {
380 | message: ERROR_CODES.NAME_REQUIRED,
381 | });
382 | }
383 |
384 | deleteAllExpiredApiKeys(ctx.context);
385 |
386 | const key = await keyGenerator({
387 | length: opts.defaultKeyLength,
388 | prefix: prefix || opts.defaultPrefix,
389 | });
390 |
391 | const hashed = opts.disableKeyHashing ? key : await defaultKeyHasher(key);
392 |
393 | let start: string | null = null;
394 |
395 | if (opts.startingCharactersConfig.shouldStore) {
396 | start = key.substring(
397 | 0,
398 | opts.startingCharactersConfig.charactersLength,
399 | );
400 | }
401 |
402 | const defaultPermissions = opts.permissions?.defaultPermissions
403 | ? typeof opts.permissions.defaultPermissions === "function"
404 | ? await opts.permissions.defaultPermissions(user.id, ctx)
405 | : opts.permissions.defaultPermissions
406 | : undefined;
407 | const permissionsToApply = permissions
408 | ? JSON.stringify(permissions)
409 | : defaultPermissions
410 | ? JSON.stringify(defaultPermissions)
411 | : undefined;
412 |
413 | let data: Omit<ApiKey, "id"> = {
414 | createdAt: new Date(),
415 | updatedAt: new Date(),
416 | name: name ?? null,
417 | prefix: prefix ?? opts.defaultPrefix ?? null,
418 | start: start,
419 | key: hashed,
420 | enabled: true,
421 | expiresAt: expiresIn
422 | ? getDate(expiresIn, "sec")
423 | : opts.keyExpiration.defaultExpiresIn
424 | ? getDate(opts.keyExpiration.defaultExpiresIn, "sec")
425 | : null,
426 | userId: user.id,
427 | lastRefillAt: null,
428 | lastRequest: null,
429 | metadata: null,
430 | rateLimitMax: rateLimitMax ?? opts.rateLimit.maxRequests ?? null,
431 | rateLimitTimeWindow:
432 | rateLimitTimeWindow ?? opts.rateLimit.timeWindow ?? null,
433 | remaining:
434 | remaining === null ? remaining : (remaining ?? refillAmount ?? null),
435 | refillAmount: refillAmount ?? null,
436 | refillInterval: refillInterval ?? null,
437 | rateLimitEnabled:
438 | rateLimitEnabled === undefined
439 | ? (opts.rateLimit.enabled ?? true)
440 | : rateLimitEnabled,
441 | requestCount: 0,
442 | //@ts-expect-error - we intentionally save the permissions as string on DB.
443 | permissions: permissionsToApply,
444 | };
445 |
446 | if (metadata) {
447 | //@ts-expect-error - we intentionally save the metadata as string on DB.
448 | data.metadata = schema.apikey.fields.metadata.transform.input(metadata);
449 | }
450 |
451 | const apiKey = await ctx.context.adapter.create<
452 | Omit<ApiKey, "id">,
453 | ApiKey
454 | >({
455 | model: API_KEY_TABLE_NAME,
456 | data: data,
457 | });
458 |
459 | return ctx.json({
460 | ...(apiKey as ApiKey),
461 | key: key,
462 | metadata: metadata ?? null,
463 | permissions: apiKey.permissions
464 | ? safeJSONParse(apiKey.permissions)
465 | : null,
466 | });
467 | },
468 | );
469 | }
470 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/api/to-auth-endpoints.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | createAuthEndpoint,
3 | createAuthMiddleware,
4 | } from "@better-auth/core/api";
5 | import { APIError } from "better-call";
6 | import { describe, expect, it } from "vitest";
7 | import * as z from "zod";
8 | import { init } from "../init";
9 | import { getTestInstance } from "../test-utils/test-instance";
10 | import { toAuthEndpoints } from "./to-auth-endpoints";
11 |
12 | describe("before hook", async () => {
13 | describe("context", async () => {
14 | const endpoints = {
15 | query: createAuthEndpoint(
16 | "/query",
17 | {
18 | method: "GET",
19 | },
20 | async (c) => {
21 | return c.query;
22 | },
23 | ),
24 | body: createAuthEndpoint(
25 | "/body",
26 | {
27 | method: "POST",
28 | },
29 | async (c) => {
30 | return c.body;
31 | },
32 | ),
33 | params: createAuthEndpoint(
34 | "/params",
35 | {
36 | method: "GET",
37 | },
38 | async (c) => {
39 | return c.params;
40 | },
41 | ),
42 | headers: createAuthEndpoint(
43 | "/headers",
44 | {
45 | method: "GET",
46 | requireHeaders: true,
47 | },
48 | async (c) => {
49 | return Object.fromEntries(c.headers.entries());
50 | },
51 | ),
52 | };
53 |
54 | const authContext = init({
55 | hooks: {
56 | before: createAuthMiddleware(async (c) => {
57 | switch (c.path) {
58 | case "/body":
59 | return {
60 | context: {
61 | body: {
62 | name: "body",
63 | },
64 | },
65 | };
66 | case "/params":
67 | return {
68 | context: {
69 | params: {
70 | name: "params",
71 | },
72 | },
73 | };
74 | case "/headers":
75 | return {
76 | context: {
77 | headers: new Headers({
78 | name: "headers",
79 | }),
80 | },
81 | };
82 | }
83 | return {
84 | context: {
85 | query: {
86 | name: "query",
87 | },
88 | },
89 | };
90 | }),
91 | },
92 | });
93 | const authEndpoints = toAuthEndpoints(endpoints, authContext);
94 |
95 | it("should return hook set query", async () => {
96 | const res = await authEndpoints.query();
97 | expect(res?.name).toBe("query");
98 | const res2 = await authEndpoints.query({
99 | query: {
100 | key: "value",
101 | },
102 | });
103 | expect(res2).toMatchObject({
104 | name: "query",
105 | key: "value",
106 | });
107 | });
108 |
109 | it("should return hook set body", async () => {
110 | const res = await authEndpoints.body();
111 | expect(res?.name).toBe("body");
112 | const res2 = await authEndpoints.body({
113 | //@ts-expect-error
114 | body: {
115 | key: "value",
116 | },
117 | });
118 | expect(res2).toMatchObject({
119 | name: "body",
120 | key: "value",
121 | });
122 | });
123 |
124 | it("should return hook set param", async () => {
125 | const res = await authEndpoints.params();
126 | expect(res?.name).toBe("params");
127 | const res2 = await authEndpoints.params({
128 | params: {
129 | key: "value",
130 | },
131 | });
132 | expect(res2).toMatchObject({
133 | name: "params",
134 | key: "value",
135 | });
136 | });
137 |
138 | it("should return hook set headers", async () => {
139 | const res = await authEndpoints.headers({
140 | headers: new Headers({
141 | key: "value",
142 | }),
143 | });
144 | expect(res).toMatchObject({ key: "value", name: "headers" });
145 | });
146 |
147 | it("should replace existing array when hook provides another array", async () => {
148 | const endpoint = {
149 | body: createAuthEndpoint(
150 | "/body-array-replace",
151 | { method: "POST", body: z.object({ tags: z.array(z.string()) }) },
152 | async (c) => c.body,
153 | ),
154 | };
155 | const authContext = init({
156 | hooks: {
157 | before: createAuthMiddleware(async (c) => {
158 | if (c.path === "/body-array-replace") {
159 | return {
160 | context: {
161 | body: {
162 | tags: ["a"],
163 | },
164 | },
165 | };
166 | }
167 | }),
168 | },
169 | });
170 | const api = toAuthEndpoints(endpoint, authContext);
171 |
172 | const res = await api.body({
173 | body: {
174 | tags: ["b", "c"],
175 | },
176 | });
177 | expect(res.tags).toEqual(["a"]);
178 | });
179 | });
180 |
181 | describe("response", async () => {
182 | const endpoints = {
183 | response: createAuthEndpoint(
184 | "/response",
185 | {
186 | method: "GET",
187 | },
188 | async (c) => {
189 | return { response: true };
190 | },
191 | ),
192 | json: createAuthEndpoint(
193 | "/json",
194 | {
195 | method: "GET",
196 | },
197 | async (c) => {
198 | return { response: true };
199 | },
200 | ),
201 | };
202 |
203 | const authContext = init({
204 | hooks: {
205 | before: createAuthMiddleware(async (c) => {
206 | if (c.path === "/json") {
207 | return { before: true };
208 | }
209 | return new Response(JSON.stringify({ before: true }));
210 | }),
211 | },
212 | });
213 | const authEndpoints = toAuthEndpoints(endpoints, authContext);
214 |
215 | it("should return Response object", async () => {
216 | const response = await authEndpoints.response();
217 | expect(response).toBeInstanceOf(Response);
218 | });
219 |
220 | it("should return the hook response", async () => {
221 | const response = await authEndpoints.json();
222 | expect(response).toMatchObject({ before: true });
223 | });
224 | });
225 | });
226 |
227 | describe("after hook", async () => {
228 | describe("response", async () => {
229 | const endpoints = {
230 | changeResponse: createAuthEndpoint(
231 | "/change-response",
232 | {
233 | method: "GET",
234 | },
235 | async (c) => {
236 | return {
237 | hello: "world",
238 | };
239 | },
240 | ),
241 | throwError: createAuthEndpoint(
242 | "/throw-error",
243 | {
244 | method: "POST",
245 | query: z
246 | .object({
247 | throwHook: z.boolean(),
248 | })
249 | .optional(),
250 | },
251 | async (c) => {
252 | throw c.error("BAD_REQUEST");
253 | },
254 | ),
255 | multipleHooks: createAuthEndpoint(
256 | "/multi-hooks",
257 | {
258 | method: "GET",
259 | },
260 | async (c) => {
261 | return {
262 | return: "1",
263 | };
264 | },
265 | ),
266 | };
267 |
268 | const authContext = init({
269 | plugins: [
270 | {
271 | id: "test",
272 | hooks: {
273 | after: [
274 | {
275 | matcher() {
276 | return true;
277 | },
278 | handler: createAuthMiddleware(async (c) => {
279 | if (c.path === "/multi-hooks") {
280 | return {
281 | return: "3",
282 | };
283 | }
284 | }),
285 | },
286 | ],
287 | },
288 | },
289 | ],
290 | hooks: {
291 | after: createAuthMiddleware(async (c) => {
292 | if (c.path === "/change-response") {
293 | return {
294 | hello: "auth",
295 | };
296 | }
297 | if (c.path === "/multi-hooks") {
298 | return {
299 | return: "2",
300 | };
301 | }
302 | if (c.query?.throwHook) {
303 | throw c.error("BAD_REQUEST", {
304 | message: "from after hook",
305 | });
306 | }
307 | }),
308 | },
309 | });
310 |
311 | const api = toAuthEndpoints(endpoints, authContext);
312 |
313 | it("should change the response object from `hello:world` to `hello:auth`", async () => {
314 | const response = await api.changeResponse();
315 | expect(response).toMatchObject({ hello: "auth" });
316 | });
317 |
318 | it("should return the last hook returned response", async () => {
319 | const response = await api.multipleHooks();
320 | expect(response).toMatchObject({
321 | return: "3",
322 | });
323 | });
324 |
325 | it("should return error as response", async () => {
326 | const response = await api.throwError({
327 | asResponse: true,
328 | });
329 | expect(response.status).toBe(400);
330 | });
331 |
332 | it("should throw the last error", async () => {
333 | await api
334 | .throwError({
335 | query: {
336 | throwHook: true,
337 | },
338 | })
339 | .catch((e) => {
340 | expect(e).toBeInstanceOf(APIError);
341 | expect(e?.message).toBe("from after hook");
342 | });
343 | });
344 | });
345 |
346 | describe("cookies", async () => {
347 | const endpoints = {
348 | cookies: createAuthEndpoint(
349 | "/cookies",
350 | {
351 | method: "POST",
352 | },
353 | async (c) => {
354 | c.setCookie("session", "value");
355 | return { hello: "world" };
356 | },
357 | ),
358 | cookieOverride: createAuthEndpoint(
359 | "/cookie",
360 | {
361 | method: "GET",
362 | },
363 | async (c) => {
364 | c.setCookie("data", "1");
365 | },
366 | ),
367 | noCookie: createAuthEndpoint(
368 | "/no-cookie",
369 | {
370 | method: "GET",
371 | },
372 | async (c) => {},
373 | ),
374 | };
375 |
376 | const authContext = init({
377 | hooks: {
378 | after: createAuthMiddleware(async (c) => {
379 | c.setHeader("key", "value");
380 | c.setCookie("data", "2");
381 | }),
382 | },
383 | });
384 |
385 | const authEndpoints = toAuthEndpoints(endpoints, authContext);
386 |
387 | it("set cookies from both hook", async () => {
388 | const result = await authEndpoints.cookies({
389 | asResponse: true,
390 | });
391 | expect(result.headers.get("set-cookie")).toContain("session=value");
392 | expect(result.headers.get("set-cookie")).toContain("data=2");
393 | });
394 |
395 | it("should override cookie", async () => {
396 | const result = await authEndpoints.cookieOverride({
397 | asResponse: true,
398 | });
399 | expect(result.headers.get("set-cookie")).toContain("data=2");
400 | });
401 |
402 | it("should only set the hook cookie", async () => {
403 | const result = await authEndpoints.noCookie({
404 | asResponse: true,
405 | });
406 | expect(result.headers.get("set-cookie")).toContain("data=2");
407 | });
408 |
409 | it("should return cookies from return headers", async () => {
410 | const result = await authEndpoints.noCookie({
411 | returnHeaders: true,
412 | });
413 | expect(result.headers.get("set-cookie")).toContain("data=2");
414 |
415 | const result2 = await authEndpoints.cookies({
416 | asResponse: true,
417 | });
418 | expect(result2.headers.get("set-cookie")).toContain("session=value");
419 | expect(result2.headers.get("set-cookie")).toContain("data=2");
420 | });
421 | });
422 | });
423 |
424 | describe("disabled paths", async () => {
425 | const { client } = await getTestInstance({
426 | disabledPaths: ["/sign-in/email"],
427 | });
428 |
429 | it("should return 404 for disabled paths", async () => {
430 | const response = await client.$fetch("/ok");
431 | expect(response.data).toEqual({ ok: true });
432 | const { error } = await client.signIn.email({
433 | email: "[email protected]",
434 | password: "test",
435 | });
436 | expect(error?.status).toBe(404);
437 | });
438 | });
439 |
440 | describe("debug mode stack trace", () => {
441 | it("should preserve stack trace when logger is in debug mode and APIError is thrown", async () => {
442 | const endpoints = {
443 | testEndpoint: createAuthEndpoint(
444 | "/test-error",
445 | { method: "GET" },
446 | async () => {
447 | throw new APIError("BAD_REQUEST", { message: "Test error" });
448 | },
449 | ),
450 | };
451 |
452 | const authContext = init({
453 | logger: {
454 | level: "debug",
455 | },
456 | });
457 |
458 | const api = toAuthEndpoints(endpoints, authContext);
459 |
460 | try {
461 | await api.testEndpoint({});
462 | } catch (error: any) {
463 | expect(error).toBeInstanceOf(APIError);
464 | expect(error.stack).toBeDefined();
465 | expect(error.stack).toMatch(/ErrorWithStack:|Error:|APIError:/);
466 | expect(error.stack).toMatch(/at\s+/);
467 | }
468 | });
469 |
470 | it("should not modify stack trace when logger is not in debug mode", async () => {
471 | const endpoints = {
472 | testEndpoint: createAuthEndpoint(
473 | "/test-error",
474 | { method: "GET" },
475 | async () => {
476 | throw new APIError("BAD_REQUEST", { message: "Test error" });
477 | },
478 | ),
479 | };
480 |
481 | const authContext = init({
482 | logger: {
483 | level: "error", // Not debug mode
484 | },
485 | });
486 |
487 | const api = toAuthEndpoints(endpoints, authContext);
488 |
489 | try {
490 | await api.testEndpoint({});
491 | } catch (error: any) {
492 | expect(error).toBeInstanceOf(APIError);
493 | // Stack should exist but may be minimal when not in debug mode
494 | expect(error.stack).toBeDefined();
495 | }
496 | });
497 |
498 | it("should have detailed stack trace in debug mode", async () => {
499 | const endpoints = {
500 | testEndpoint: createAuthEndpoint(
501 | "/test-error",
502 | { method: "GET" },
503 | async () => {
504 | throw new APIError("INTERNAL_SERVER_ERROR", {
505 | message: "Internal error occurred",
506 | });
507 | },
508 | ),
509 | };
510 |
511 | const authContext = init({
512 | logger: {
513 | level: "debug",
514 | },
515 | });
516 |
517 | const api = toAuthEndpoints(endpoints, authContext);
518 |
519 | try {
520 | await api.testEndpoint({});
521 | } catch (error: any) {
522 | expect(error).toBeInstanceOf(APIError);
523 | expect(error.stack).toBeDefined();
524 | // Check for stack trace format
525 | expect(error.stack).toMatch(/at\s+.*\(.*\)/); // Match "at functionName (file:line:col)"
526 | expect(error.stack).toMatch(/\.ts:\d+:\d+/); // Match TypeScript file with line:column
527 | }
528 | });
529 |
530 | it("should handle APIError in hooks with debug mode", async () => {
531 | const endpoints = {
532 | testEndpoint: createAuthEndpoint(
533 | "/test-hook-error",
534 | { method: "GET" },
535 | async () => {
536 | return { data: "success" };
537 | },
538 | ),
539 | };
540 |
541 | const authContext = init({
542 | logger: {
543 | level: "debug",
544 | },
545 | hooks: {
546 | before: createAuthMiddleware(async () => {
547 | throw new APIError("FORBIDDEN", { message: "Forbidden action" });
548 | }),
549 | },
550 | });
551 |
552 | const api = toAuthEndpoints(endpoints, authContext);
553 |
554 | try {
555 | await api.testEndpoint({});
556 | } catch (error: any) {
557 | expect(error).toBeInstanceOf(APIError);
558 | expect(error.stack).toBeDefined();
559 | expect(error.stack).toMatch(/ErrorWithStack:|Error:|APIError:/);
560 | expect(error.stack).toMatch(/at\s+/);
561 | }
562 | });
563 |
564 | it("should handle Response containing APIError in debug mode", async () => {
565 | const endpoints = {
566 | testEndpoint: createAuthEndpoint(
567 | "/test-response-error",
568 | { method: "GET" },
569 | async () => {
570 | throw new APIError("UNAUTHORIZED", {
571 | message: "Unauthorized access",
572 | });
573 | },
574 | ),
575 | };
576 |
577 | const authContext = init({
578 | logger: {
579 | level: "debug",
580 | },
581 | });
582 |
583 | const api = toAuthEndpoints(endpoints, authContext);
584 |
585 | // Test with asResponse = true to get Response object
586 | const response = await api.testEndpoint({ asResponse: true });
587 | expect(response).toBeInstanceOf(Response);
588 | expect(response.status).toBe(401);
589 |
590 | // Test with asResponse = false to get thrown error
591 | try {
592 | await api.testEndpoint({ asResponse: false });
593 | } catch (error: any) {
594 | expect(error).toBeInstanceOf(APIError);
595 | expect(error.stack).toBeDefined();
596 | expect(error.stack).toMatch(/ErrorWithStack:|Error:|APIError:/);
597 | }
598 | });
599 | });
600 |
```
--------------------------------------------------------------------------------
/packages/expo/src/client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { BetterAuthClientPlugin, ClientStore } from "@better-auth/core";
2 | import type { BetterFetchOption } from "@better-fetch/fetch";
3 | import Constants from "expo-constants";
4 | import * as Linking from "expo-linking";
5 | import { Platform } from "react-native";
6 |
7 | interface CookieAttributes {
8 | value: string;
9 | expires?: Date;
10 | "max-age"?: number;
11 | domain?: string;
12 | path?: string;
13 | secure?: boolean;
14 | httpOnly?: boolean;
15 | sameSite?: "Strict" | "Lax" | "None";
16 | }
17 |
18 | export function parseSetCookieHeader(
19 | header: string,
20 | ): Map<string, CookieAttributes> {
21 | const cookieMap = new Map<string, CookieAttributes>();
22 | const cookies = splitSetCookieHeader(header);
23 | cookies.forEach((cookie) => {
24 | const parts = cookie.split(";").map((p) => p.trim());
25 | const [nameValue, ...attributes] = parts;
26 | const [name, ...valueParts] = nameValue!.split("=");
27 | const value = valueParts.join("=");
28 | const cookieObj: CookieAttributes = { value };
29 | attributes.forEach((attr) => {
30 | const [attrName, ...attrValueParts] = attr.split("=");
31 | const attrValue = attrValueParts.join("=");
32 | cookieObj[attrName!.toLowerCase() as "value"] = attrValue;
33 | });
34 | cookieMap.set(name!, cookieObj);
35 | });
36 | return cookieMap;
37 | }
38 |
39 | function splitSetCookieHeader(setCookie: string): string[] {
40 | const parts: string[] = [];
41 | let buffer = "";
42 | let i = 0;
43 | while (i < setCookie.length) {
44 | const char = setCookie[i];
45 | if (char === ",") {
46 | const recent = buffer.toLowerCase();
47 | const hasExpires = recent.includes("expires=");
48 | const hasGmt = /gmt/i.test(recent);
49 | if (hasExpires && !hasGmt) {
50 | buffer += char;
51 | i += 1;
52 | continue;
53 | }
54 | if (buffer.trim().length > 0) {
55 | parts.push(buffer.trim());
56 | buffer = "";
57 | }
58 | i += 1;
59 | if (setCookie[i] === " ") i += 1;
60 | continue;
61 | }
62 | buffer += char;
63 | i += 1;
64 | }
65 | if (buffer.trim().length > 0) {
66 | parts.push(buffer.trim());
67 | }
68 | return parts;
69 | }
70 |
71 | interface ExpoClientOptions {
72 | scheme?: string;
73 | storage: {
74 | setItem: (key: string, value: string) => any;
75 | getItem: (key: string) => string | null;
76 | };
77 | /**
78 | * Prefix for local storage keys (e.g., "my-app_cookie", "my-app_session_data")
79 | * @default "better-auth"
80 | */
81 | storagePrefix?: string;
82 | /**
83 | * Prefix for server cookie names to filter (e.g., "better-auth.session_token")
84 | * This is used to identify which cookies belong to better-auth to prevent
85 | * infinite refetching when third-party cookies are set.
86 | * @default "better-auth"
87 | */
88 | cookiePrefix?: string;
89 | disableCache?: boolean;
90 | }
91 |
92 | interface StoredCookie {
93 | value: string;
94 | expires: string | null;
95 | }
96 |
97 | export function getSetCookie(header: string, prevCookie?: string) {
98 | const parsed = parseSetCookieHeader(header);
99 | let toSetCookie: Record<string, StoredCookie> = {};
100 | parsed.forEach((cookie, key) => {
101 | const expiresAt = cookie["expires"];
102 | const maxAge = cookie["max-age"];
103 | const expires = maxAge
104 | ? new Date(Date.now() + Number(maxAge) * 1000)
105 | : expiresAt
106 | ? new Date(String(expiresAt))
107 | : null;
108 | toSetCookie[key] = {
109 | value: cookie["value"],
110 | expires: expires ? expires.toISOString() : null,
111 | };
112 | });
113 | if (prevCookie) {
114 | try {
115 | const prevCookieParsed = JSON.parse(prevCookie);
116 | toSetCookie = {
117 | ...prevCookieParsed,
118 | ...toSetCookie,
119 | };
120 | } catch {
121 | //
122 | }
123 | }
124 | return JSON.stringify(toSetCookie);
125 | }
126 |
127 | export function getCookie(cookie: string) {
128 | let parsed = {} as Record<string, StoredCookie>;
129 | try {
130 | parsed = JSON.parse(cookie) as Record<string, StoredCookie>;
131 | } catch (e) {}
132 | const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
133 | if (value.expires && new Date(value.expires) < new Date()) {
134 | return acc;
135 | }
136 | return `${acc}; ${key}=${value.value}`;
137 | }, "");
138 | return toSend;
139 | }
140 |
141 | function getOrigin(scheme: string) {
142 | const schemeURI = Linking.createURL("", { scheme });
143 | return schemeURI;
144 | }
145 |
146 | /**
147 | * Compare if session cookies have actually changed by comparing their values.
148 | * Ignores expiry timestamps that naturally change on each request.
149 | *
150 | * @param prevCookie - Previous cookie JSON string
151 | * @param newCookie - New cookie JSON string
152 | * @returns true if session cookies have changed, false otherwise
153 | */
154 | function hasSessionCookieChanged(
155 | prevCookie: string | null,
156 | newCookie: string,
157 | ): boolean {
158 | if (!prevCookie) return true;
159 |
160 | try {
161 | const prev = JSON.parse(prevCookie) as Record<string, StoredCookie>;
162 | const next = JSON.parse(newCookie) as Record<string, StoredCookie>;
163 |
164 | // Get all session-related cookie keys (session_token, session_data)
165 | const sessionKeys = new Set<string>();
166 | Object.keys(prev).forEach((key) => {
167 | if (key.includes("session_token") || key.includes("session_data")) {
168 | sessionKeys.add(key);
169 | }
170 | });
171 | Object.keys(next).forEach((key) => {
172 | if (key.includes("session_token") || key.includes("session_data")) {
173 | sessionKeys.add(key);
174 | }
175 | });
176 |
177 | // Compare the values of session cookies (ignore expires timestamps)
178 | for (const key of sessionKeys) {
179 | const prevValue = prev[key]?.value;
180 | const nextValue = next[key]?.value;
181 | if (prevValue !== nextValue) {
182 | return true;
183 | }
184 | }
185 |
186 | return false;
187 | } catch {
188 | // If parsing fails, assume cookie changed
189 | return true;
190 | }
191 | }
192 |
193 | /**
194 | * Check if the Set-Cookie header contains session-related better-auth cookies.
195 | * Only triggers session updates when session_token or session_data cookies are present.
196 | * This prevents infinite refetching when non-session cookies (like third-party cookies) change.
197 | *
198 | * Supports multiple cookie naming patterns:
199 | * - Default: "better-auth.session_token", "__Secure-better-auth.session_token"
200 | * - Custom prefix: "myapp.session_token", "__Secure-myapp.session_token"
201 | * - Custom full names: "my_custom_session_token", "custom_session_data"
202 | * - No prefix (cookiePrefix=""): "session_token", "my_session_token", etc.
203 | *
204 | * @param setCookieHeader - The Set-Cookie header value
205 | * @param cookiePrefix - The cookie prefix to check for. Can be empty string for custom cookie names.
206 | * @returns true if the header contains session-related cookies, false otherwise
207 | */
208 | export function hasBetterAuthCookies(
209 | setCookieHeader: string,
210 | cookiePrefix: string,
211 | ): boolean {
212 | const cookies = parseSetCookieHeader(setCookieHeader);
213 | const sessionCookieSuffixes = ["session_token", "session_data"];
214 |
215 | // Check if any cookie is a session-related cookie
216 | for (const name of cookies.keys()) {
217 | // Remove __Secure- prefix if present for comparison
218 | const nameWithoutSecure = name.startsWith("__Secure-")
219 | ? name.slice(9)
220 | : name;
221 |
222 | for (const suffix of sessionCookieSuffixes) {
223 | if (cookiePrefix) {
224 | // When prefix is provided, only match exact pattern: "prefix.suffix"
225 | if (nameWithoutSecure === `${cookiePrefix}.${suffix}`) {
226 | return true;
227 | }
228 | } else {
229 | // When prefix is empty, check for:
230 | // 1. Exact match: "session_token"
231 | // 2. Custom names ending with suffix: "my_custom_session_token"
232 | if (nameWithoutSecure.endsWith(suffix)) {
233 | return true;
234 | }
235 | }
236 | }
237 | }
238 | return false;
239 | }
240 |
241 | /**
242 | * Expo secure store does not support colons in the keys.
243 | * This function replaces colons with underscores.
244 | *
245 | * @see https://github.com/better-auth/better-auth/issues/5426
246 | *
247 | * @param name cookie name to be saved in the storage
248 | * @returns normalized cookie name
249 | */
250 | export function normalizeCookieName(name: string) {
251 | return name.replace(/:/g, "_");
252 | }
253 |
254 | export function storageAdapter(storage: {
255 | getItem: (name: string) => string | null;
256 | setItem: (name: string, value: string) => void;
257 | }) {
258 | return {
259 | getItem: (name: string) => {
260 | return storage.getItem(normalizeCookieName(name));
261 | },
262 | setItem: (name: string, value: string) => {
263 | return storage.setItem(normalizeCookieName(name), value);
264 | },
265 | };
266 | }
267 |
268 | export const expoClient = (opts: ExpoClientOptions) => {
269 | let store: ClientStore | null = null;
270 | const storagePrefix = opts?.storagePrefix || "better-auth";
271 | const cookieName = `${storagePrefix}_cookie`;
272 | const localCacheName = `${storagePrefix}_session_data`;
273 | const storage = storageAdapter(opts?.storage);
274 | const isWeb = Platform.OS === "web";
275 | const cookiePrefix = opts?.cookiePrefix || "better-auth";
276 |
277 | const rawScheme =
278 | opts?.scheme || Constants.expoConfig?.scheme || Constants.platform?.scheme;
279 | const scheme = Array.isArray(rawScheme) ? rawScheme[0] : rawScheme;
280 |
281 | if (!scheme && !isWeb) {
282 | throw new Error(
283 | "Scheme not found in app.json. Please provide a scheme in the options.",
284 | );
285 | }
286 | return {
287 | id: "expo",
288 | getActions(_, $store) {
289 | store = $store;
290 | return {
291 | /**
292 | * Get the stored cookie.
293 | *
294 | * You can use this to get the cookie stored in the device and use it in your fetch
295 | * requests.
296 | *
297 | * @example
298 | * ```ts
299 | * const cookie = client.getCookie();
300 | * fetch("https://api.example.com", {
301 | * headers: {
302 | * cookie,
303 | * },
304 | * });
305 | */
306 | getCookie: () => {
307 | const cookie = storage.getItem(cookieName);
308 | return getCookie(cookie || "{}");
309 | },
310 | };
311 | },
312 | fetchPlugins: [
313 | {
314 | id: "expo",
315 | name: "Expo",
316 | hooks: {
317 | async onSuccess(context) {
318 | if (isWeb) return;
319 | const setCookie = context.response.headers.get("set-cookie");
320 | if (setCookie) {
321 | // Only process and notify if the Set-Cookie header contains better-auth cookies
322 | // This prevents infinite refetching when other cookies (like Cloudflare's __cf_bm) are present
323 | if (hasBetterAuthCookies(setCookie, cookiePrefix)) {
324 | const prevCookie = await storage.getItem(cookieName);
325 | const toSetCookie = getSetCookie(
326 | setCookie || "",
327 | prevCookie ?? undefined,
328 | );
329 | // Only notify $sessionSignal if the session cookie values actually changed
330 | // This prevents infinite refetching when the server sends the same cookie with updated expiry
331 | if (hasSessionCookieChanged(prevCookie, toSetCookie)) {
332 | await storage.setItem(cookieName, toSetCookie);
333 | store?.notify("$sessionSignal");
334 | } else {
335 | // Still update the storage to refresh expiry times, but don't trigger refetch
336 | await storage.setItem(cookieName, toSetCookie);
337 | }
338 | }
339 | }
340 |
341 | if (
342 | context.request.url.toString().includes("/get-session") &&
343 | !opts?.disableCache
344 | ) {
345 | const data = context.data;
346 | storage.setItem(localCacheName, JSON.stringify(data));
347 | }
348 |
349 | if (
350 | context.data?.redirect &&
351 | (context.request.url.toString().includes("/sign-in") ||
352 | context.request.url.toString().includes("/link-social")) &&
353 | !context.request?.body.includes("idToken") // id token is used for silent sign-in
354 | ) {
355 | const callbackURL = JSON.parse(context.request.body)?.callbackURL;
356 | const to = callbackURL;
357 | const signInURL = context.data?.url;
358 | let Browser: typeof import("expo-web-browser") | undefined =
359 | undefined;
360 | try {
361 | Browser = await import("expo-web-browser");
362 | } catch (error) {
363 | throw new Error(
364 | '"expo-web-browser" is not installed as a dependency!',
365 | {
366 | cause: error,
367 | },
368 | );
369 | }
370 | const proxyURL = `${context.request.baseURL}/expo-authorization-proxy?authorizationURL=${encodeURIComponent(signInURL)}`;
371 | const result = await Browser.openAuthSessionAsync(proxyURL, to);
372 | if (result.type !== "success") return;
373 | const url = new URL(result.url);
374 | const cookie = String(url.searchParams.get("cookie"));
375 | if (!cookie) return;
376 | storage.setItem(cookieName, getSetCookie(cookie));
377 | store?.notify("$sessionSignal");
378 | }
379 | },
380 | },
381 | async init(url, options) {
382 | if (isWeb) {
383 | return {
384 | url,
385 | options: options as BetterFetchOption,
386 | };
387 | }
388 | options = options || {};
389 | const storedCookie = storage.getItem(cookieName);
390 | const cookie = getCookie(storedCookie || "{}");
391 | options.credentials = "omit";
392 | options.headers = {
393 | ...options.headers,
394 | cookie,
395 | "expo-origin": getOrigin(scheme!),
396 | "x-skip-oauth-proxy": "true", // skip oauth proxy for expo
397 | };
398 | if (options.body?.callbackURL) {
399 | if (options.body.callbackURL.startsWith("/")) {
400 | const url = Linking.createURL(options.body.callbackURL, {
401 | scheme,
402 | });
403 | options.body.callbackURL = url;
404 | }
405 | }
406 | if (options.body?.newUserCallbackURL) {
407 | if (options.body.newUserCallbackURL.startsWith("/")) {
408 | const url = Linking.createURL(options.body.newUserCallbackURL, {
409 | scheme,
410 | });
411 | options.body.newUserCallbackURL = url;
412 | }
413 | }
414 | if (options.body?.errorCallbackURL) {
415 | if (options.body.errorCallbackURL.startsWith("/")) {
416 | const url = Linking.createURL(options.body.errorCallbackURL, {
417 | scheme,
418 | });
419 | options.body.errorCallbackURL = url;
420 | }
421 | }
422 | if (url.includes("/sign-out")) {
423 | await storage.setItem(cookieName, "{}");
424 | store?.atoms.session?.set({
425 | ...store.atoms.session.get(),
426 | data: null,
427 | error: null,
428 | isPending: false,
429 | });
430 | storage.setItem(localCacheName, "{}");
431 | }
432 | return {
433 | url,
434 | options: options as BetterFetchOption,
435 | };
436 | },
437 | },
438 | ],
439 | } satisfies BetterAuthClientPlugin;
440 | };
441 |
```