This is page 39 of 49. Use http://codebase.md/better-auth/better-auth?page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/routes/crud-team.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { createAuthEndpoint } from "@better-auth/core/api"; import { getOrgAdapter } from "../adapter"; import { orgMiddleware, orgSessionMiddleware } from "../call"; import { APIError } from "better-call"; import { getSessionFromCtx } from "../../../api"; import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import type { OrganizationOptions } from "../types"; import { teamSchema } from "../schema"; import { hasPermission } from "../has-permission"; import { setSessionCookie } from "../../../cookies"; import { toZodSchema, type InferAdditionalFieldsFromPluginOptions, } from "../../../db"; import type { PrettifyDeep } from "../../../types/helper"; export const createTeam = <O extends OrganizationOptions>(options: O) => { const additionalFieldsSchema = toZodSchema({ fields: options?.schema?.team?.additionalFields ?? {}, isClientSide: true, }); const baseSchema = z.object({ name: z.string().meta({ description: 'The name of the team. Eg: "my-team"', }), organizationId: z .string() .meta({ description: 'The organization ID which the team will be created in. Defaults to the active organization. Eg: "organization-id"', }) .optional(), }); return createAuthEndpoint( "/organization/create-team", { method: "POST", body: z.object({ ...baseSchema.shape, ...additionalFieldsSchema.shape, }), use: [orgMiddleware], metadata: { $Infer: { body: {} as z.infer<typeof baseSchema> & InferAdditionalFieldsFromPluginOptions<"team", O>, }, openapi: { description: "Create a new team within an organization", responses: { "200": { description: "Team created successfully", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string", description: "Unique identifier of the created team", }, name: { type: "string", description: "Name of the team", }, organizationId: { type: "string", description: "ID of the organization the team belongs to", }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the team was created", }, updatedAt: { type: "string", format: "date-time", description: "Timestamp when the team was last updated", }, }, required: [ "id", "name", "organizationId", "createdAt", "updatedAt", ], }, }, }, }, }, }, }, }, async (ctx) => { const session = await getSessionFromCtx(ctx); const organizationId = ctx.body.organizationId || session?.session.activeOrganizationId; if (!session && (ctx.request || ctx.headers)) { throw new APIError("UNAUTHORIZED"); } if (!organizationId) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } const adapter = getOrgAdapter<O>(ctx.context, options as O); if (session) { const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId, }); if (!member) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION, }); } const canCreate = await hasPermission( { role: member.role, options: ctx.context.orgOptions, permissions: { team: ["create"], }, organizationId, }, ctx, ); if (!canCreate) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION, }); } } const existingTeams = await adapter.listTeams(organizationId); const maximum = typeof ctx.context.orgOptions.teams?.maximumTeams === "function" ? await ctx.context.orgOptions.teams?.maximumTeams( { organizationId, session, }, ctx.request, ) : ctx.context.orgOptions.teams?.maximumTeams; const maxTeamsReached = maximum ? existingTeams.length >= maximum : false; if (maxTeamsReached) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS, }); } const { name, organizationId: _, ...additionalFields } = ctx.body; const organization = await adapter.findOrganizationById(organizationId); if (!organization) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, }); } let teamData = { name, organizationId, createdAt: new Date(), updatedAt: new Date(), ...additionalFields, }; // Run beforeCreateTeam hook if (options?.organizationHooks?.beforeCreateTeam) { const response = await options?.organizationHooks.beforeCreateTeam({ team: { name, organizationId, ...additionalFields, }, user: session?.user, organization, }); if (response && typeof response === "object" && "data" in response) { teamData = { ...teamData, ...response.data, }; } } const createdTeam = await adapter.createTeam(teamData); // Run afterCreateTeam hook if (options?.organizationHooks?.afterCreateTeam) { await options?.organizationHooks.afterCreateTeam({ team: createdTeam, user: session?.user, organization, }); } return ctx.json(createdTeam); }, ); }; export const removeTeam = <O extends OrganizationOptions>(options: O) => createAuthEndpoint( "/organization/remove-team", { method: "POST", body: z.object({ teamId: z.string().meta({ description: `The team ID of the team to remove. Eg: "team-id"`, }), organizationId: z .string() .meta({ description: `The organization ID which the team falls under. If not provided, it will default to the user's active organization. Eg: "organization-id"`, }) .optional(), }), use: [orgMiddleware], metadata: { openapi: { description: "Remove a team from an organization", responses: { "200": { description: "Team removed successfully", content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", description: "Confirmation message indicating successful removal", enum: ["Team removed successfully."], }, }, required: ["message"], }, }, }, }, }, }, }, }, async (ctx) => { const session = await getSessionFromCtx(ctx); const organizationId = ctx.body.organizationId || session?.session.activeOrganizationId; if (!organizationId) { return ctx.json(null, { status: 400, body: { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }, }); } if (!session && (ctx.request || ctx.headers)) { throw new APIError("UNAUTHORIZED"); } const adapter = getOrgAdapter<O>(ctx.context, options); if (session) { const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId, }); if (!member || session.session?.activeTeamId === ctx.body.teamId) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM, }); } const canRemove = await hasPermission( { role: member.role, options: ctx.context.orgOptions, permissions: { team: ["delete"], }, organizationId, }, ctx, ); if (!canRemove) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION, }); } } const team = await adapter.findTeamById({ teamId: ctx.body.teamId, organizationId, }); if (!team || team.organizationId !== organizationId) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND, }); } if (!ctx.context.orgOptions.teams?.allowRemovingAllTeams) { const teams = await adapter.listTeams(organizationId); if (teams.length <= 1) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.UNABLE_TO_REMOVE_LAST_TEAM, }); } } const organization = await adapter.findOrganizationById(organizationId); if (!organization) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, }); } // Run beforeDeleteTeam hook if (options?.organizationHooks?.beforeDeleteTeam) { await options?.organizationHooks.beforeDeleteTeam({ team, user: session?.user, organization, }); } await adapter.deleteTeam(team.id); // Run afterDeleteTeam hook if (options?.organizationHooks?.afterDeleteTeam) { await options?.organizationHooks.afterDeleteTeam({ team, user: session?.user, organization, }); } return ctx.json({ message: "Team removed successfully." }); }, ); export const updateTeam = <O extends OrganizationOptions>(options: O) => { const additionalFieldsSchema = toZodSchema({ fields: options?.schema?.team?.additionalFields ?? {}, isClientSide: true, }); type Body = { teamId: string; data: Partial< PrettifyDeep< Omit<z.infer<typeof teamSchema>, "id" | "createdAt" | "updatedAt"> > & InferAdditionalFieldsFromPluginOptions<"team", O> >; }; return createAuthEndpoint( "/organization/update-team", { method: "POST", body: z.object({ teamId: z.string().meta({ description: `The ID of the team to be updated. Eg: "team-id"`, }), data: z .object({ ...teamSchema.shape, ...additionalFieldsSchema.shape, }) .partial(), }), requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware], metadata: { $Infer: { body: {} as Body }, openapi: { description: "Update an existing team in an organization", responses: { "200": { description: "Team updated successfully", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string", description: "Unique identifier of the updated team", }, name: { type: "string", description: "Updated name of the team", }, organizationId: { type: "string", description: "ID of the organization the team belongs to", }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the team was created", }, updatedAt: { type: "string", format: "date-time", description: "Timestamp when the team was last updated", }, }, required: [ "id", "name", "organizationId", "createdAt", "updatedAt", ], }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; const organizationId = ctx.body.data.organizationId || session.session.activeOrganizationId; if (!organizationId) { return ctx.json(null, { status: 400, body: { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }, }); } const adapter = getOrgAdapter<O>(ctx.context, options); const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId, }); if (!member) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM, }); } const canUpdate = await hasPermission( { role: member.role, options: ctx.context.orgOptions, permissions: { team: ["update"], }, organizationId, }, ctx, ); if (!canUpdate) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM, }); } const team = await adapter.findTeamById({ teamId: ctx.body.teamId, organizationId, }); if (!team || team.organizationId !== organizationId) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND, }); } const { name, organizationId: __, ...additionalFields } = ctx.body.data; const organization = await adapter.findOrganizationById(organizationId); if (!organization) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, }); } const updates = { name, ...additionalFields, }; // Run beforeUpdateTeam hook if (options?.organizationHooks?.beforeUpdateTeam) { const response = await options?.organizationHooks.beforeUpdateTeam({ team, updates, user: session.user, organization, }); if (response && typeof response === "object" && "data" in response) { // Allow the hook to modify the updates const modifiedUpdates = response.data; const updatedTeam = await adapter.updateTeam( team.id, modifiedUpdates, ); // Run afterUpdateTeam hook if (options?.organizationHooks?.afterUpdateTeam) { await options?.organizationHooks.afterUpdateTeam({ team: updatedTeam, user: session.user, organization, }); } return ctx.json(updatedTeam); } } const updatedTeam = await adapter.updateTeam(team.id, updates); // Run afterUpdateTeam hook if (options?.organizationHooks?.afterUpdateTeam) { await options?.organizationHooks.afterUpdateTeam({ team: updatedTeam, user: session.user, organization, }); } return ctx.json(updatedTeam); }, ); }; export const listOrganizationTeams = <O extends OrganizationOptions>( options: O, ) => createAuthEndpoint( "/organization/list-teams", { method: "GET", query: z.optional( z.object({ organizationId: z .string() .meta({ description: `The organization ID which the teams are under to list. Defaults to the users active organization. Eg: "organziation-id"`, }) .optional(), }), ), requireHeaders: true, metadata: { openapi: { description: "List all teams in an organization", responses: { "200": { description: "Teams retrieved successfully", content: { "application/json": { schema: { type: "array", items: { type: "object", properties: { id: { type: "string", description: "Unique identifier of the team", }, name: { type: "string", description: "Name of the team", }, organizationId: { type: "string", description: "ID of the organization the team belongs to", }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the team was created", }, updatedAt: { type: "string", format: "date-time", description: "Timestamp when the team was last updated", }, }, required: [ "id", "name", "organizationId", "createdAt", "updatedAt", ], }, description: "Array of team objects within the organization", }, }, }, }, }, }, }, use: [orgMiddleware, orgSessionMiddleware], }, async (ctx) => { const session = ctx.context.session; const organizationId = ctx.query?.organizationId || session?.session.activeOrganizationId; if (!organizationId) { throw ctx.error("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } const adapter = getOrgAdapter<O>(ctx.context, options); const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId: organizationId || "", }); if (!member) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION, }); } const teams = await adapter.listTeams(organizationId); return ctx.json(teams); }, ); export const setActiveTeam = <O extends OrganizationOptions>(options: O) => createAuthEndpoint( "/organization/set-active-team", { method: "POST", body: z.object({ teamId: z .string() .meta({ description: "The team id to set as active. It can be null to unset the active team", }) .nullable() .optional(), }), use: [orgSessionMiddleware, orgMiddleware], metadata: { openapi: { description: "Set the active team", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", description: "The team", $ref: "#/components/schemas/Team", }, }, }, }, }, }, }, }, async (ctx) => { const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions); const session = ctx.context.session; if (ctx.body.teamId === null) { const sessionTeamId = session.session.activeTeamId; if (!sessionTeamId) { return ctx.json(null); } const updatedSession = await adapter.setActiveTeam( session.session.token, null, ctx, ); await setSessionCookie(ctx, { session: updatedSession, user: session.user, }); return ctx.json(null); } let teamId: string; if (!ctx.body.teamId) { const sessionTeamId = session.session.activeTeamId; if (!sessionTeamId) { return ctx.json(null); } else { teamId = sessionTeamId; } } else { teamId = ctx.body.teamId; } const team = await adapter.findTeamById({ teamId }); if (!team) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND, }); } const member = await adapter.findTeamMember({ teamId, userId: session.user.id, }); if (!member) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM, }); } const updatedSession = await adapter.setActiveTeam( session.session.token, team.id, ctx, ); await setSessionCookie(ctx, { session: updatedSession, user: session.user, }); return ctx.json(team); }, ); export const listUserTeams = <O extends OrganizationOptions>(options: O) => createAuthEndpoint( "/organization/list-user-teams", { method: "GET", metadata: { openapi: { description: "List all teams that the current user is a part of.", responses: { "200": { description: "Teams retrieved successfully", content: { "application/json": { schema: { type: "array", items: { type: "object", description: "The team", $ref: "#/components/schemas/Team", }, description: "Array of team objects within the organization", }, }, }, }, }, }, }, use: [orgMiddleware, orgSessionMiddleware], }, async (ctx) => { const session = ctx.context.session; const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions); const teams = await adapter.listTeamsByUser({ userId: session.user.id, }); return ctx.json(teams); }, ); export const listTeamMembers = <O extends OrganizationOptions>(options: O) => createAuthEndpoint( "/organization/list-team-members", { method: "GET", query: z.optional( z.object({ teamId: z.string().optional().meta({ description: "The team whose members we should return. If this is not provided the members of the current active team get returned.", }), }), ), metadata: { openapi: { description: "List the members of the given team.", responses: { "200": { description: "Teams retrieved successfully", content: { "application/json": { schema: { type: "array", items: { type: "object", description: "The team member", properties: { id: { type: "string", description: "Unique identifier of the team member", }, userId: { type: "string", description: "The user ID of the team member", }, teamId: { type: "string", description: "The team ID of the team the team member is in", }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the team member was created", }, }, required: ["id", "userId", "teamId", "createdAt"], }, description: "Array of team member objects within the team", }, }, }, }, }, }, }, use: [orgMiddleware, orgSessionMiddleware], }, async (ctx) => { const session = ctx.context.session; const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions); let teamId = ctx.query?.teamId || session?.session.activeTeamId; if (!teamId) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM, }); } const member = await adapter.findTeamMember({ userId: session.user.id, teamId, }); if (!member) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM, }); } const members = await adapter.listTeamMembers({ teamId, }); return ctx.json(members); }, ); export const addTeamMember = <O extends OrganizationOptions>(options: O) => createAuthEndpoint( "/organization/add-team-member", { method: "POST", body: z.object({ teamId: z.string().meta({ description: "The team the user should be a member of.", }), userId: z.coerce.string().meta({ description: "The user Id which represents the user to be added as a member.", }), }), metadata: { openapi: { description: "The newly created member", responses: { "200": { description: "Team member created successfully", content: { "application/json": { schema: { type: "object", description: "The team member", properties: { id: { type: "string", description: "Unique identifier of the team member", }, userId: { type: "string", description: "The user ID of the team member", }, teamId: { type: "string", description: "The team ID of the team the team member is in", }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the team member was created", }, }, required: ["id", "userId", "teamId", "createdAt"], }, }, }, }, }, }, }, use: [orgMiddleware, orgSessionMiddleware], }, async (ctx) => { const session = ctx.context.session; const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions); if (!session.session.activeOrganizationId) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } const currentMember = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId: session.session.activeOrganizationId, }); if (!currentMember) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION, }); } const canUpdateMember = await hasPermission( { role: currentMember.role, options: ctx.context.orgOptions, permissions: { member: ["update"], }, organizationId: session.session.activeOrganizationId, }, ctx, ); if (!canUpdateMember) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER, }); } const toBeAddedMember = await adapter.findMemberByOrgId({ userId: ctx.body.userId, organizationId: session.session.activeOrganizationId, }); if (!toBeAddedMember) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION, }); } const team = await adapter.findTeamById({ teamId: ctx.body.teamId, organizationId: session.session.activeOrganizationId, }); if (!team) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND, }); } const organization = await adapter.findOrganizationById( session.session.activeOrganizationId, ); if (!organization) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, }); } const userBeingAdded = await ctx.context.internalAdapter.findUserById( ctx.body.userId, ); if (!userBeingAdded) { throw new APIError("BAD_REQUEST", { message: "User not found", }); } // Run beforeAddTeamMember hook if (options?.organizationHooks?.beforeAddTeamMember) { const response = await options?.organizationHooks.beforeAddTeamMember({ teamMember: { teamId: ctx.body.teamId, userId: ctx.body.userId, }, team, user: userBeingAdded, organization, }); if (response && typeof response === "object" && "data" in response) { // Allow the hook to modify the data } } const teamMember = await adapter.findOrCreateTeamMember({ teamId: ctx.body.teamId, userId: ctx.body.userId, }); // Run afterAddTeamMember hook if (options?.organizationHooks?.afterAddTeamMember) { await options?.organizationHooks.afterAddTeamMember({ teamMember, team, user: userBeingAdded, organization, }); } return ctx.json(teamMember); }, ); export const removeTeamMember = <O extends OrganizationOptions>(options: O) => createAuthEndpoint( "/organization/remove-team-member", { method: "POST", body: z.object({ teamId: z.string().meta({ description: "The team the user should be removed from.", }), userId: z.coerce.string().meta({ description: "The user which should be removed from the team.", }), }), metadata: { openapi: { description: "Remove a member from a team", responses: { "200": { description: "Team member removed successfully", content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", description: "Confirmation message indicating successful removal", enum: ["Team member removed successfully."], }, }, required: ["message"], }, }, }, }, }, }, }, use: [orgMiddleware, orgSessionMiddleware], }, async (ctx) => { const session = ctx.context.session; const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions); if (!session.session.activeOrganizationId) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } const currentMember = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId: session.session.activeOrganizationId, }); if (!currentMember) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION, }); } const canDeleteMember = await hasPermission( { role: currentMember.role, options: ctx.context.orgOptions, permissions: { member: ["delete"], }, organizationId: session.session.activeOrganizationId, }, ctx, ); if (!canDeleteMember) { throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER, }); } const toBeAddedMember = await adapter.findMemberByOrgId({ userId: ctx.body.userId, organizationId: session.session.activeOrganizationId, }); if (!toBeAddedMember) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION, }); } const team = await adapter.findTeamById({ teamId: ctx.body.teamId, organizationId: session.session.activeOrganizationId, }); if (!team) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND, }); } const organization = await adapter.findOrganizationById( session.session.activeOrganizationId, ); if (!organization) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, }); } const userBeingRemoved = await ctx.context.internalAdapter.findUserById( ctx.body.userId, ); if (!userBeingRemoved) { throw new APIError("BAD_REQUEST", { message: "User not found", }); } const teamMember = await adapter.findTeamMember({ teamId: ctx.body.teamId, userId: ctx.body.userId, }); if (!teamMember) { throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM, }); } // Run beforeRemoveTeamMember hook if (options?.organizationHooks?.beforeRemoveTeamMember) { await options?.organizationHooks.beforeRemoveTeamMember({ teamMember, team, user: userBeingRemoved, organization, }); } await adapter.removeTeamMember({ teamId: ctx.body.teamId, userId: ctx.body.userId, }); // Run afterRemoveTeamMember hook if (options?.organizationHooks?.afterRemoveTeamMember) { await options?.organizationHooks.afterRemoveTeamMember({ teamMember, team, user: userBeingRemoved, organization, }); } return ctx.json({ message: "Team member removed successfully." }); }, ); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/routes/crud-access-control.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { APIError } from "../../../api"; import { createAuthEndpoint } from "@better-auth/core/api"; import type { OrganizationOptions } from "../types"; import { orgSessionMiddleware } from "../call"; import { hasPermission } from "../has-permission"; import type { Member, OrganizationRole } from "../schema"; import type { User } from "../../../types"; import type { Where } from "@better-auth/core/db/adapter"; import type { AccessControl } from "../../access"; import { toZodSchema, type InferAdditionalFieldsFromPluginOptions, } from "../../../db"; import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import type { GenericEndpointContext } from "@better-auth/core"; type IsExactlyEmptyObject<T> = keyof T extends never // no keys ? T extends {} // is assignable to {} ? {} extends T ? true : false // and {} is assignable to it : false : false; const normalizeRoleName = (role: string) => role.toLowerCase(); const DEFAULT_MAXIMUM_ROLES_PER_ORGANIZATION = Number.POSITIVE_INFINITY; const getAdditionalFields = < O extends OrganizationOptions, AllPartial extends boolean = false, >( options: O, shouldBePartial: AllPartial = false as AllPartial, ) => { let additionalFields = options?.schema?.organizationRole?.additionalFields || {}; if (shouldBePartial) { for (const key in additionalFields) { additionalFields[key]!.required = false; } } const additionalFieldsSchema = toZodSchema({ fields: additionalFields, isClientSide: true, }); type AdditionalFields = AllPartial extends true ? Partial<InferAdditionalFieldsFromPluginOptions<"organizationRole", O>> : InferAdditionalFieldsFromPluginOptions<"organizationRole", O>; type ReturnAdditionalFields = InferAdditionalFieldsFromPluginOptions< "organizationRole", O, false >; return { additionalFieldsSchema, $AdditionalFields: {} as AdditionalFields, $ReturnAdditionalFields: {} as ReturnAdditionalFields, }; }; export const createOrgRole = <O extends OrganizationOptions>(options: O) => { const { additionalFieldsSchema, $AdditionalFields, $ReturnAdditionalFields } = getAdditionalFields<O>(options, false); type AdditionalFields = typeof $AdditionalFields; type ReturnAdditionalFields = typeof $ReturnAdditionalFields; return createAuthEndpoint( "/organization/create-role", { method: "POST", body: z.object({ organizationId: z.string().optional().meta({ description: "The id of the organization to create the role in. If not provided, the user's active organization will be used.", }), role: z.string().meta({ description: "The name of the role to create", }), permission: z.record(z.string(), z.array(z.string())).meta({ description: "The permission to assign to the role", }), additionalFields: z .object({ ...additionalFieldsSchema.shape }) .optional(), }), metadata: { $Infer: { body: {} as { organizationId?: string | undefined; role: string; permission: Record<string, string[]>; } & (IsExactlyEmptyObject<AdditionalFields> extends true ? { additionalFields?: {} } : { additionalFields: AdditionalFields }), }, }, requireHeaders: true, use: [orgSessionMiddleware], }, async (ctx) => { const { session, user } = ctx.context.session; let roleName = ctx.body.role; const permission = ctx.body.permission; const additionalFields = ctx.body.additionalFields; const ac = options.ac; if (!ac) { ctx.context.logger.error( `[Dynamic Access Control] The organization plugin is missing a pre-defined ac instance.`, `\nPlease refer to the documentation here: https://better-auth.com/docs/plugins/organization#dynamic-access-control`, ); throw new APIError("NOT_IMPLEMENTED", { message: ORGANIZATION_ERROR_CODES.MISSING_AC_INSTANCE, }); } // Get the organization id where the role will be created. // We can verify if the org id is valid and associated with the user in the next step when we try to find the member. const organizationId = ctx.body.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error( `[Dynamic Access Control] The session is missing an active organization id to create a role. Either set an active org id, or pass an organizationId in the request body.`, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE, }); } roleName = normalizeRoleName(roleName); await checkIfRoleNameIsTakenByPreDefinedRole({ role: roleName, organizationId, options, ctx, }); // Get the user's role associated with the organization. // This also serves as a check to ensure the org id is valid. const member = await ctx.context.adapter.findOne<Member>({ model: "member", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, { field: "userId", value: user.id, operator: "eq", connector: "AND", }, ], }); if (!member) { ctx.context.logger.error( `[Dynamic Access Control] The user is not a member of the organization to create a role.`, { userId: user.id, organizationId, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, }); } const canCreateRole = await hasPermission( { options, organizationId, permissions: { ac: ["create"], }, role: member.role, }, ctx, ); if (!canCreateRole) { ctx.context.logger.error( `[Dynamic Access Control] The user is not permitted to create a role. If this is unexpected, please make sure the role associated to that member has the "ac" resource with the "create" permission.`, { userId: user.id, organizationId, role: member.role, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, }); } const maximumRolesPerOrganization = typeof options.dynamicAccessControl?.maximumRolesPerOrganization === "function" ? await options.dynamicAccessControl.maximumRolesPerOrganization( organizationId, ) : (options.dynamicAccessControl?.maximumRolesPerOrganization ?? DEFAULT_MAXIMUM_ROLES_PER_ORGANIZATION); const rolesInDB = await ctx.context.adapter.count({ model: "organizationRole", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, ], }); if (rolesInDB >= maximumRolesPerOrganization) { ctx.context.logger.error( `[Dynamic Access Control] Failed to create a new role, the organization has too many roles. Maximum allowed roles is ${maximumRolesPerOrganization}.`, { organizationId, maximumRolesPerOrganization, rolesInDB, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TOO_MANY_ROLES, }); } await checkForInvalidResources({ ac, ctx, permission }); await checkIfMemberHasPermission({ ctx, member, options, organizationId, permissionRequired: permission, user, action: "create", }); await checkIfRoleNameIsTakenByRoleInDB({ ctx, organizationId, role: roleName, }); const newRole = ac.newRole(permission); const newRoleInDB = await ctx.context.adapter.create< Omit<OrganizationRole, "permission"> & { permission: string } >({ model: "organizationRole", data: { createdAt: new Date(), organizationId, permission: JSON.stringify(permission), role: roleName, ...additionalFields, }, }); const data = { ...newRoleInDB, permission, } as OrganizationRole & ReturnAdditionalFields; return ctx.json({ success: true, roleData: data, statements: newRole.statements, }); }, ); }; export const deleteOrgRole = <O extends OrganizationOptions>(options: O) => { return createAuthEndpoint( "/organization/delete-role", { method: "POST", body: z .object({ organizationId: z.string().optional().meta({ description: "The id of the organization to create the role in. If not provided, the user's active organization will be used.", }), }) .and( z.union([ z.object({ roleName: z.string().nonempty().meta({ description: "The name of the role to delete", }), }), z.object({ roleId: z.string().nonempty().meta({ description: "The id of the role to delete", }), }), ]), ), requireHeaders: true, use: [orgSessionMiddleware], metadata: { $Infer: { body: {} as { roleName?: string | undefined; roleId?: string | undefined; organizationId?: string | undefined; }, }, }, }, async (ctx) => { const { session, user } = ctx.context.session; // We can verify if the org id is valid and associated with the user in the next step when we try to find the member. const organizationId = ctx.body.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error( `[Dynamic Access Control] The session is missing an active organization id to delete a role. Either set an active org id, or pass an organizationId in the request body.`, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } // Get the user's role associated with the organization. // This also serves as a check to ensure the org id is valid. const member = await ctx.context.adapter.findOne<Member>({ model: "member", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, { field: "userId", value: user.id, operator: "eq", connector: "AND", }, ], }); if (!member) { ctx.context.logger.error( `[Dynamic Access Control] The user is not a member of the organization to delete a role.`, { userId: user.id, organizationId, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, }); } const canDeleteRole = await hasPermission( { options, organizationId, permissions: { ac: ["delete"], }, role: member.role, }, ctx, ); if (!canDeleteRole) { ctx.context.logger.error( `[Dynamic Access Control] The user is not permitted to delete a role. If this is unexpected, please make sure the role associated to that member has the "ac" resource with the "delete" permission.`, { userId: user.id, organizationId, role: member.role, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE, }); } if (ctx.body.roleName) { const roleName = ctx.body.roleName; const defaultRoles = options.roles ? Object.keys(options.roles) : ["owner", "admin", "member"]; if (defaultRoles.includes(roleName)) { ctx.context.logger.error( `[Dynamic Access Control] Cannot delete a pre-defined role.`, { roleName, organizationId, defaultRoles, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.CANNOT_DELETE_A_PRE_DEFINED_ROLE, }); } } let condition: Where; if (ctx.body.roleName) { condition = { field: "role", value: ctx.body.roleName, operator: "eq", connector: "AND", }; } else if (ctx.body.roleId) { condition = { field: "id", value: ctx.body.roleId, operator: "eq", connector: "AND", }; } else { // shouldn't be able to reach here given the schema validation. // But just in case, throw an error. ctx.context.logger.error( `[Dynamic Access Control] The role name/id is not provided in the request body.`, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND, }); } const existingRoleInDB = await ctx.context.adapter.findOne<OrganizationRole>({ model: "organizationRole", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, condition, ], }); if (!existingRoleInDB) { ctx.context.logger.error( `[Dynamic Access Control] The role name/id does not exist in the database.`, { ...("roleName" in ctx.body ? { roleName: ctx.body.roleName } : { roleId: ctx.body.roleId }), organizationId, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND, }); } existingRoleInDB.permission = JSON.parse( existingRoleInDB.permission as never as string, ); await ctx.context.adapter.delete({ model: "organizationRole", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, condition, ], }); return ctx.json({ success: true, }); }, ); }; export const listOrgRoles = <O extends OrganizationOptions>(options: O) => { const { $ReturnAdditionalFields } = getAdditionalFields<O>(options, false); type ReturnAdditionalFields = typeof $ReturnAdditionalFields; return createAuthEndpoint( "/organization/list-roles", { method: "GET", use: [orgSessionMiddleware], query: z .object({ organizationId: z.string().optional().meta({ description: "The id of the organization to list roles for. If not provided, the user's active organization will be used.", }), }) .optional(), }, async (ctx) => { const { session, user } = ctx.context.session; const organizationId = ctx.query?.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error( `[Dynamic Access Control] The session is missing an active organization id to list roles. Either set an active org id, or pass an organizationId in the request query.`, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } const member = await ctx.context.adapter.findOne<Member>({ model: "member", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, { field: "userId", value: user.id, operator: "eq", connector: "AND", }, ], }); if (!member) { ctx.context.logger.error( `[Dynamic Access Control] The user is not a member of the organization to list roles.`, { userId: user.id, organizationId, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, }); } const canListRoles = await hasPermission( { options, organizationId, permissions: { ac: ["read"], }, role: member.role, }, ctx, ); if (!canListRoles) { ctx.context.logger.error( `[Dynamic Access Control] The user is not permitted to list roles.`, { userId: user.id, organizationId, role: member.role, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE, }); } let roles = await ctx.context.adapter.findMany< OrganizationRole & ReturnAdditionalFields >({ model: "organizationRole", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, ], }); roles = roles.map((x) => ({ ...x, permission: JSON.parse(x.permission as never as string), })); return ctx.json(roles); }, ); }; export const getOrgRole = <O extends OrganizationOptions>(options: O) => { const { $ReturnAdditionalFields } = getAdditionalFields<O>(options, false); type ReturnAdditionalFields = typeof $ReturnAdditionalFields; return createAuthEndpoint( "/organization/get-role", { method: "GET", use: [orgSessionMiddleware], query: z .object({ organizationId: z.string().optional().meta({ description: "The id of the organization to read a role for. If not provided, the user's active organization will be used.", }), }) .and( z.union([ z.object({ roleName: z.string().nonempty().meta({ description: "The name of the role to read", }), }), z.object({ roleId: z.string().nonempty().meta({ description: "The id of the role to read", }), }), ]), ) .optional(), metadata: { $Infer: { query: {} as { organizationId?: string | undefined; roleName?: string | undefined; roleId?: string | undefined; }, }, }, }, async (ctx) => { const { session, user } = ctx.context.session; const organizationId = ctx.query?.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error( `[Dynamic Access Control] The session is missing an active organization id to read a role. Either set an active org id, or pass an organizationId in the request query.`, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } const member = await ctx.context.adapter.findOne<Member>({ model: "member", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, { field: "userId", value: user.id, operator: "eq", connector: "AND", }, ], }); if (!member) { ctx.context.logger.error( `[Dynamic Access Control] The user is not a member of the organization to read a role.`, { userId: user.id, organizationId, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, }); } const canListRoles = await hasPermission( { options, organizationId, permissions: { ac: ["read"], }, role: member.role, }, ctx, ); if (!canListRoles) { ctx.context.logger.error( `[Dynamic Access Control] The user is not permitted to read a role.`, { userId: user.id, organizationId, role: member.role, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE, }); } let condition: Where; if (ctx.query.roleName) { condition = { field: "role", value: ctx.query.roleName, operator: "eq", connector: "AND", }; } else if (ctx.query.roleId) { condition = { field: "id", value: ctx.query.roleId, operator: "eq", connector: "AND", }; } else { // shouldn't be able to reach here given the schema validation. // But just in case, throw an error. ctx.context.logger.error( `[Dynamic Access Control] The role name/id is not provided in the request query.`, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND, }); } let role = await ctx.context.adapter.findOne<OrganizationRole>({ model: "organizationRole", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, condition, ], }); if (!role) { ctx.context.logger.error( `[Dynamic Access Control] The role name/id does not exist in the database.`, { ...("roleName" in ctx.query ? { roleName: ctx.query.roleName } : { roleId: ctx.query.roleId }), organizationId, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND, }); } role.permission = JSON.parse(role.permission as never as string); return ctx.json(role as OrganizationRole & ReturnAdditionalFields); }, ); }; export const updateOrgRole = <O extends OrganizationOptions>(options: O) => { const { additionalFieldsSchema, $AdditionalFields, $ReturnAdditionalFields } = getAdditionalFields<O, true>(options, true); type AdditionalFields = typeof $AdditionalFields; type ReturnAdditionalFields = typeof $ReturnAdditionalFields; return createAuthEndpoint( "/organization/update-role", { method: "POST", body: z .object({ organizationId: z.string().optional().meta({ description: "The id of the organization to update the role in. If not provided, the user's active organization will be used.", }), data: z.object({ permission: z .record(z.string(), z.array(z.string())) .optional() .meta({ description: "The permission to update the role with", }), roleName: z.string().optional().meta({ description: "The name of the role to update", }), ...additionalFieldsSchema.shape, }), }) .and( z.union([ z.object({ roleName: z.string().nonempty().meta({ description: "The name of the role to update", }), }), z.object({ roleId: z.string().nonempty().meta({ description: "The id of the role to update", }), }), ]), ), metadata: { $Infer: { body: {} as { organizationId?: string | undefined; data: { permission?: Record<string, string[]> | undefined; roleName?: string | undefined; } & AdditionalFields; roleName?: string | undefined; roleId?: string | undefined; }, }, }, use: [orgSessionMiddleware], }, async (ctx) => { const { session, user } = ctx.context.session; const ac = options.ac; if (!ac) { ctx.context.logger.error( `[Dynamic Access Control] The organization plugin is missing a pre-defined ac instance.`, `\nPlease refer to the documentation here: https://better-auth.com/docs/plugins/organization#dynamic-access-control`, ); throw new APIError("NOT_IMPLEMENTED", { message: ORGANIZATION_ERROR_CODES.MISSING_AC_INSTANCE, }); } const organizationId = ctx.body.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error( `[Dynamic Access Control] The session is missing an active organization id to update a role. Either set an active org id, or pass an organizationId in the request body.`, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, }); } const member = await ctx.context.adapter.findOne<Member>({ model: "member", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, { field: "userId", value: user.id, operator: "eq", connector: "AND", }, ], }); if (!member) { ctx.context.logger.error( `[Dynamic Access Control] The user is not a member of the organization to update a role.`, { userId: user.id, organizationId, }, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, }); } const canUpdateRole = await hasPermission( { options, organizationId, role: member.role, permissions: { ac: ["update"], }, }, ctx, ); if (!canUpdateRole) { ctx.context.logger.error( `[Dynamic Access Control] The user is not permitted to update a role.`, ); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE, }); } let condition: Where; if (ctx.body.roleName) { condition = { field: "role", value: ctx.body.roleName, operator: "eq", connector: "AND", }; } else if (ctx.body.roleId) { condition = { field: "id", value: ctx.body.roleId, operator: "eq", connector: "AND", }; } else { // shouldn't be able to reach here given the schema validation. // But just in case, throw an error. ctx.context.logger.error( `[Dynamic Access Control] The role name/id is not provided in the request body.`, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND, }); } let role = await ctx.context.adapter.findOne<OrganizationRole>({ model: "organizationRole", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, condition, ], }); if (!role) { ctx.context.logger.error( `[Dynamic Access Control] The role name/id does not exist in the database.`, { ...("roleName" in ctx.body ? { roleName: ctx.body.roleName } : { roleId: ctx.body.roleId }), organizationId, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND, }); } role.permission = role.permission ? JSON.parse(role.permission as never as string) : undefined; const { permission: _, roleName: __, ...additionalFields } = ctx.body.data; let updateData: Partial<OrganizationRole> = { ...additionalFields, }; if (ctx.body.data.permission) { let newPermission = ctx.body.data.permission; await checkForInvalidResources({ ac, ctx, permission: newPermission }); await checkIfMemberHasPermission({ ctx, member, options, organizationId, permissionRequired: newPermission, user, action: "update", }); updateData.permission = newPermission; } if (ctx.body.data.roleName) { let newRoleName = ctx.body.data.roleName; newRoleName = normalizeRoleName(newRoleName); await checkIfRoleNameIsTakenByPreDefinedRole({ role: newRoleName, organizationId, options, ctx, }); await checkIfRoleNameIsTakenByRoleInDB({ role: newRoleName, organizationId, ctx, }); updateData.role = newRoleName; } // ----- // Apply the updates const update = { ...updateData, ...(updateData.permission ? { permission: JSON.stringify(updateData.permission) } : {}), }; await ctx.context.adapter.update<OrganizationRole>({ model: "organizationRole", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, condition, ], update, }); // ----- // Return the updated role return ctx.json({ success: true, roleData: { ...role, ...update, permission: updateData.permission || role.permission || null, } as OrganizationRole & ReturnAdditionalFields, }); }, ); }; async function checkForInvalidResources({ ac, ctx, permission, }: { ac: AccessControl; ctx: GenericEndpointContext; permission: Record<string, string[]>; }) { const validResources = Object.keys(ac.statements); const providedResources = Object.keys(permission); const hasInvalidResource = providedResources.some( (r) => !validResources.includes(r), ); if (hasInvalidResource) { ctx.context.logger.error( `[Dynamic Access Control] The provided permission includes an invalid resource.`, { providedResources, validResources, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.INVALID_RESOURCE, }); } } async function checkIfMemberHasPermission({ ctx, permissionRequired: permission, options, organizationId, member, user, action, }: { ctx: GenericEndpointContext; permissionRequired: Record<string, string[]>; options: OrganizationOptions; organizationId: string; member: Member; user: User; action: "create" | "update" | "delete" | "read" | "list" | "get"; }) { const hasNecessaryPermissions: { resource: { [x: string]: string[] }; hasPermission: boolean; }[] = []; const permissionEntries = Object.entries(permission); for await (const [resource, permissions] of permissionEntries) { for await (const perm of permissions) { hasNecessaryPermissions.push({ resource: { [resource]: [perm] }, hasPermission: await hasPermission( { options, organizationId, permissions: { [resource]: [perm] }, useMemoryCache: true, role: member.role, }, ctx, ), }); } } const missingPermissions = hasNecessaryPermissions .filter((x) => x.hasPermission === false) .map((x) => { const key = Object.keys(x.resource)[0]!; return `${key}:${x.resource[key]![0]}` as const; }); if (missingPermissions.length > 0) { ctx.context.logger.error( `[Dynamic Access Control] The user is missing permissions nessesary to ${action} a role with those set of permissions.\n`, { userId: user.id, organizationId, role: member.role, missingPermissions, }, ); let errorMessage: string; if (action === "create") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE; else if (action === "update") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE; else if (action === "delete") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE; else if (action === "read") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE; else if (action === "list") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE; else errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE; throw new APIError("FORBIDDEN", { message: errorMessage, missingPermissions, }); } } async function checkIfRoleNameIsTakenByPreDefinedRole({ options, organizationId, role, ctx, }: { options: OrganizationOptions; organizationId: string; role: string; ctx: GenericEndpointContext; }) { const defaultRoles = options.roles ? Object.keys(options.roles) : ["owner", "admin", "member"]; if (defaultRoles.includes(role)) { ctx.context.logger.error( `[Dynamic Access Control] The role name "${role}" is already taken by a pre-defined role.`, { role, organizationId, defaultRoles, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NAME_IS_ALREADY_TAKEN, }); } } async function checkIfRoleNameIsTakenByRoleInDB({ organizationId, role, ctx, }: { ctx: GenericEndpointContext; organizationId: string; role: string; }) { const existingRoleInDB = await ctx.context.adapter.findOne<OrganizationRole>({ model: "organizationRole", where: [ { field: "organizationId", value: organizationId, operator: "eq", connector: "AND", }, { field: "role", value: role, operator: "eq", connector: "AND", }, ], }); if (existingRoleInDB) { ctx.context.logger.error( `[Dynamic Access Control] The role name "${role}" is already taken by a role in the database.`, { role, organizationId, }, ); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NAME_IS_ALREADY_TAKEN, }); } } ```