From 8b29096deb1c9de5721e9a6d6cd2eefd1bb33b37 Mon Sep 17 00:00:00 2001
From: olliethedev <5933733+olliethedev@users.noreply.github.com>
Date: Sat, 22 Aug 2026 13:55:49 -0400
Subject: [PATCH] feat: bridge Better Auth UI to BTST v3 providers
---
.github/workflows/release.yml | 103 +++++++++-
docs/content/docs/integrations/btst-v3.mdx | 191 ++++++++++++++++++
docs/content/docs/integrations/meta.json | 1 +
src/client.ts | 1 +
src/index.ts | 1 +
.../__tests__/better-auth-provider.test.ts | 174 ++++++++++++++++
src/lib/__tests__/package-metadata.test.ts | 31 +++
.../__tests__/plugin-context-bridge.test.tsx | 166 ++++++++++++++-
src/lib/better-auth-provider-shared.ts | 58 ++++++
src/lib/better-auth-provider.ts | 78 +++++++
src/lib/better-auth-server-provider.ts | 104 ++++++++++
src/lib/plugin-context-bridge.tsx | 100 ++++++---
src/lib/stack-adapters.ts | 15 ++
src/plugins/auth-plugin.ts | 26 ---
src/server.ts | 8 +
15 files changed, 993 insertions(+), 64 deletions(-)
create mode 100644 docs/content/docs/integrations/btst-v3.mdx
create mode 100644 src/lib/__tests__/better-auth-provider.test.ts
create mode 100644 src/lib/better-auth-provider-shared.ts
create mode 100644 src/lib/better-auth-provider.ts
create mode 100644 src/lib/better-auth-server-provider.ts
create mode 100644 src/lib/stack-adapters.ts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 54f92d09d..b30556e8e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,9 +3,20 @@ name: Better Auth UI Release
on:
release:
types: [published]
+ workflow_dispatch:
+ inputs:
+ release_tag:
+ description: Existing v* tag to publish
+ required: true
+ type: string
+ prerelease:
+ description: Publish with the npm next dist-tag
+ required: true
+ default: true
+ type: boolean
permissions:
- contents: write
+ contents: read
id-token: write
jobs:
@@ -15,10 +26,13 @@ jobs:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
+ ref: ${{ github.event.release.tag_name || inputs.release_tag }}
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
+ with:
+ version: 10.26.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -26,8 +40,8 @@ jobs:
node-version: '22.18.0'
registry-url: 'https://registry.npmjs.org'
- - name: Update npm
- run: npm install -g npm@latest
+ - name: Install npm with trusted publishing support
+ run: npm install -g npm@11.17.0
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -38,15 +52,18 @@ jobs:
- name: Resolve release metadata
id: release-metadata
env:
- GITHUB_PRERELEASE: ${{ github.event.release.prerelease }}
+ GITHUB_PRERELEASE: ${{ github.event.release.prerelease || inputs.prerelease }}
+ RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
run: |
+ set -euo pipefail
+
PKG_VERSION=$(node -p "require('./package.json').version")
- if [ -n "${{ github.event.release.tag_name }}" ]; then
- RAW_TAG="${{ github.event.release.tag_name }}"
- else
- RAW_TAG="${GITHUB_REF#refs/tags/}"
+ if [[ ! "$RELEASE_TAG" =~ ^v ]]; then
+ echo "Release tag must start with v; found $RELEASE_TAG"
+ exit 1
fi
- TAG_VERSION="${RAW_TAG#v}"
+
+ TAG_VERSION="${RELEASE_TAG#v}"
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)"
exit 1
@@ -62,4 +79,70 @@ jobs:
echo "Publishing @btst/better-auth-ui@$PKG_VERSION with dist-tag '$NPM_DIST_TAG'"
- name: Publish to NPM
- run: npm publish --access public --tag "${{ steps.release-metadata.outputs.npm_dist_tag }}"
+ env:
+ NPM_DIST_TAG: ${{ steps.release-metadata.outputs.npm_dist_tag }}
+ PKG_VERSION: ${{ steps.release-metadata.outputs.package_version }}
+ run: |
+ set -euo pipefail
+
+ if npm view "@btst/better-auth-ui@$PKG_VERSION" version >/dev/null 2>&1; then
+ echo "@btst/better-auth-ui@$PKG_VERSION is already published; skipping."
+ exit 0
+ fi
+ npm publish --access public --provenance --tag "$NPM_DIST_TAG"
+
+ - name: Verify published package
+ env:
+ NPM_DIST_TAG: ${{ steps.release-metadata.outputs.npm_dist_tag }}
+ PKG_VERSION: ${{ steps.release-metadata.outputs.package_version }}
+ run: |
+ set -euo pipefail
+
+ PUBLISHED_VERSION=""
+ for attempt in {1..12}; do
+ PUBLISHED_VERSION=$(npm view "@btst/better-auth-ui@$NPM_DIST_TAG" version 2>/dev/null || true)
+ if [ "$PUBLISHED_VERSION" = "$PKG_VERSION" ]; then
+ break
+ fi
+ echo "Waiting for npm to resolve $NPM_DIST_TAG to $PKG_VERSION (attempt $attempt/12)"
+ sleep 10
+ done
+
+ if [ "$PUBLISHED_VERSION" != "$PKG_VERSION" ]; then
+ echo "@btst/better-auth-ui@$NPM_DIST_TAG resolves to ${PUBLISHED_VERSION:-nothing}, expected $PKG_VERSION"
+ exit 1
+ fi
+
+ INTEGRITY=$(npm view "@btst/better-auth-ui@$PKG_VERSION" dist.integrity)
+ if [ -z "$INTEGRITY" ]; then
+ echo "Published package integrity metadata is missing"
+ exit 1
+ fi
+
+ LATEST_SUMMARY=""
+ if [ "$NPM_DIST_TAG" = "next" ]; then
+ LATEST_VERSION=$(npm view "@btst/better-auth-ui@latest" version 2>/dev/null || true)
+ if [ "$LATEST_VERSION" = "$PKG_VERSION" ] || [[ "$LATEST_VERSION" == *-* ]]; then
+ echo "Prerelease publication must not move latest to a prerelease; found $LATEST_VERSION"
+ exit 1
+ fi
+ LATEST_SUMMARY="@btst/better-auth-ui@latest remains at ${LATEST_VERSION:-no published version}"
+ fi
+
+ {
+ echo "### npm release"
+ echo ""
+ echo "@btst/better-auth-ui@$PKG_VERSION published with dist-tag $NPM_DIST_TAG."
+ echo "Integrity: \`$INTEGRITY\`"
+ if [ -n "$LATEST_SUMMARY" ]; then
+ echo "$LATEST_SUMMARY"
+ fi
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload npm logs on failure
+ if: failure()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: npm-debug-logs
+ path: /home/runner/.npm/_logs/
+ retention-days: 7
diff --git a/docs/content/docs/integrations/btst-v3.mdx b/docs/content/docs/integrations/btst-v3.mdx
new file mode 100644
index 000000000..565e4a8b1
--- /dev/null
+++ b/docs/content/docs/integrations/btst-v3.mdx
@@ -0,0 +1,191 @@
+---
+title: BTST v3
+description: Configure Better Auth UI as the client and server auth provider for BTST v3
+---
+
+`@btst/better-auth-ui` supplies the Better Auth client plugins, page routes, and
+the first-party auth-provider adapters used by BTST v3. Router, API,
+notification, localization, and auth services are configured once at the top
+level instead of repeated in each plugin override.
+
+## Install the release candidate
+
+```bash
+pnpm add @btst/stack@next @btst/better-auth-ui@next @btst/yar@^1.3.0
+```
+
+## Register the client plugins
+
+```tsx title="lib/stack-client.tsx"
+import { createStackClient } from "@btst/stack/client"
+import {
+ accountClientPlugin,
+ authClientPlugin,
+ organizationClientPlugin,
+} from "@btst/better-auth-ui/client"
+
+export function getStackClient() {
+ return createStackClient({
+ basePath: "/p",
+ plugins: {
+ auth: authClientPlugin({
+ siteBasePath: "/p",
+ siteBaseURL: process.env.NEXT_PUBLIC_APP_URL!,
+ }),
+ account: accountClientPlugin({
+ siteBasePath: "/p",
+ siteBaseURL: process.env.NEXT_PUBLIC_APP_URL!,
+ }),
+ organization: organizationClientPlugin({
+ siteBasePath: "/p",
+ siteBaseURL: process.env.NEXT_PUBLIC_APP_URL!,
+ }),
+ },
+ })
+}
+```
+
+## Configure the client provider
+
+`createBetterAuthProvider` maps the Better Auth session to BTST identity. It
+leaves Better Auth UI's native permission hook in place unless an explicit
+authorization mapping is configured. Set `permissionProvider` only when the
+matching Better Auth client plugin is installed; resource/action checks are
+then sent to that plugin's `hasPermission` endpoint and anonymous checks fail
+closed.
+
+```tsx title="app/p/layout.tsx"
+"use client"
+
+import { StackProvider, type StackI18nProvider, type StackNotifyProvider } from "@btst/stack/context"
+import { nextRouter } from "@btst/stack/next"
+import { createBetterAuthProvider } from "@btst/better-auth-ui"
+import {
+ type AccountPluginOverrides,
+ type AuthPluginOverrides,
+ type OrganizationPluginOverrides,
+} from "@btst/better-auth-ui/client"
+import { toast } from "sonner"
+import { authClient } from "@/lib/auth-client"
+import { translateForApp } from "@/lib/i18n"
+
+type PluginOverrides = {
+ auth: AuthPluginOverrides
+ account: AccountPluginOverrides
+ organization: OrganizationPluginOverrides
+}
+
+const stackAuth = createBetterAuthProvider(authClient, {
+ loginPath: "/p/auth/sign-in",
+ permissionProvider: "organization",
+})
+
+const notify: StackNotifyProvider = {
+ success: toast.success,
+ error: toast.error,
+ info: toast.info,
+ warning: toast.warning,
+}
+
+const i18n: StackI18nProvider = {
+ translate: (key, defaultValue, params) =>
+ translateForApp(key, { defaultValue, ...params }),
+}
+
+export default function PagesLayout({ children }: { children: React.ReactNode }) {
+ const baseURL =
+ typeof window === "undefined"
+ ? process.env.NEXT_PUBLIC_APP_URL!
+ : window.location.origin
+
+ return (
+
+ basePath="/p"
+ router={nextRouter()}
+ api={{ baseURL, basePath: "/api/data" }}
+ auth={stackAuth}
+ notify={notify}
+ i18n={i18n}
+ overrides={{
+ auth: {
+ authClient,
+ basePath: "/p/auth",
+ redirectTo: "/p/account/settings",
+ },
+ account: {
+ authClient,
+ basePath: "/p/account",
+ account: true,
+ },
+ organization: {
+ authClient,
+ basePath: "/p/organization",
+ organization: true,
+ },
+ }}
+ >
+ {children}
+
+ )
+}
+```
+
+Better Auth UI localization keys are passed to the top-level translator as
+`better-auth-ui.KEY`, with the package's English string as `defaultValue`.
+Toasts use the top-level notification provider, and navigation/session refresh
+use the top-level router.
+
+## Configure the server provider
+
+The server entry exports the corresponding adapter for `stack({ auth })`. It
+passes the incoming headers to Better Auth and memoizes the session result for
+the request. BTST also shares that identity with all lifecycle hooks handling
+the request.
+
+```ts title="lib/stack.ts"
+import { stack } from "@btst/stack/api"
+import { createBetterAuthProvider } from "@btst/better-auth-ui/server"
+import { auth } from "@/lib/auth"
+import { adapter } from "@/lib/btst-adapter"
+
+export const { handler } = stack({
+ basePath: "/api/data",
+ adapter,
+ plugins: {},
+ auth: createBetterAuthProvider(auth, {
+ permissionProvider: "organization",
+ }),
+})
+```
+
+If the Better Auth organization plugin is not configured, omit
+`permissionProvider`. To use the admin plugin instead, set it to `"admin"` on
+both the client and server adapters.
+
+## Add the v3 entry factories
+
+```ts title="app/api/data/[[...all]]/route.ts"
+import { toNextRouteHandlers } from "@btst/stack/next"
+import { handler } from "@/lib/stack"
+
+export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler)
+```
+
+```tsx title="app/p/[[...all]]/page.tsx"
+import { createNextPage } from "@btst/stack/next"
+import { getOrCreateQueryClient } from "@/lib/query-client"
+import { getStackClient } from "@/lib/stack-client"
+
+export const dynamic = "force-dynamic"
+
+const page = createNextPage({
+ getStackClient,
+ getQueryClient: getOrCreateQueryClient,
+})
+
+export default page.Page
+export const generateMetadata = page.generateMetadata
+```
+
+Finally, import `@btst/better-auth-ui/css` from the application's global
+stylesheet.
diff --git a/docs/content/docs/integrations/meta.json b/docs/content/docs/integrations/meta.json
index f17996a74..21483c635 100644
--- a/docs/content/docs/integrations/meta.json
+++ b/docs/content/docs/integrations/meta.json
@@ -2,6 +2,7 @@
"icon": "LayoutGrid",
"defaultOpen": true,
"pages": [
+ "btst-v3",
"next-js",
"tanstack-start",
"react"
diff --git a/src/client.ts b/src/client.ts
index 210620a7b..9ba8fd30f 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,5 +1,6 @@
// Re-export plugins and types
+export * from "./lib/better-auth-provider"
export type {
AccountClientConfig,
AccountPageProps,
diff --git a/src/index.ts b/src/index.ts
index 63193a087..3341ee5fb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -64,6 +64,7 @@ export * from "./hooks/use-auth-data"
export * from "./hooks/use-authenticate"
export * from "./hooks/use-current-organization"
export * from "./lib/auth-ui-provider"
+export * from "./lib/better-auth-provider"
export * from "./lib/social-providers"
export { getViewByPath } from "./lib/utils"
export * from "./lib/view-paths"
diff --git a/src/lib/__tests__/better-auth-provider.test.ts b/src/lib/__tests__/better-auth-provider.test.ts
new file mode 100644
index 000000000..be1ce29c1
--- /dev/null
+++ b/src/lib/__tests__/better-auth-provider.test.ts
@@ -0,0 +1,174 @@
+import { describe, expect, it, vi } from "vitest"
+import { createBetterAuthProvider } from "../better-auth-provider"
+import { createBetterAuthServerProvider } from "../better-auth-server-provider"
+
+describe("createBetterAuthProvider", () => {
+ it("maps the Better Auth session user to a stack identity", async () => {
+ const getSession = vi.fn().mockResolvedValue({
+ data: {
+ user: {
+ id: "user-1",
+ name: "Ada",
+ email: "ada@example.com",
+ image: null,
+ role: "admin"
+ }
+ }
+ })
+
+ const provider = createBetterAuthProvider({ getSession })
+
+ await expect(provider.getIdentity()).resolves.toEqual({
+ id: "user-1",
+ name: "Ada",
+ email: "ada@example.com",
+ role: "admin"
+ })
+ expect(provider.loginPath).toBe("/auth/sign-in")
+ })
+
+ it("returns null for an unauthenticated session", async () => {
+ const provider = createBetterAuthProvider({
+ getSession: vi.fn().mockResolvedValue({ data: null })
+ })
+
+ await expect(provider.getIdentity()).resolves.toBeNull()
+ })
+
+ it("leaves permissions to Better Auth unless a mapping is configured", () => {
+ const provider = createBetterAuthProvider({
+ getSession: vi.fn().mockResolvedValue({ data: null })
+ })
+
+ expect(provider.can).toBeUndefined()
+ })
+
+ it("maps checks to the Better Auth organization permission endpoint", async () => {
+ const hasPermission = vi.fn().mockResolvedValue({
+ data: { success: true }
+ })
+ const provider = createBetterAuthProvider(
+ {
+ getSession: vi.fn().mockResolvedValue({ data: null }),
+ organization: { hasPermission }
+ },
+ { permissionProvider: "organization" }
+ )
+
+ await expect(
+ provider.can?.({
+ resource: "post",
+ action: "update",
+ params: { organizationId: "org-1" },
+ identity: { id: "user-1" }
+ })
+ ).resolves.toBe(true)
+ expect(hasPermission).toHaveBeenCalledWith({
+ organizationId: "org-1",
+ permissions: { post: ["update"] }
+ })
+ })
+
+ it("maps checks to the Better Auth admin permission endpoint", async () => {
+ const hasPermission = vi.fn().mockResolvedValue({ success: false })
+ const provider = createBetterAuthProvider(
+ {
+ getSession: vi.fn().mockResolvedValue({ data: null }),
+ admin: { hasPermission }
+ },
+ { loginPath: "/login", permissionProvider: "admin" }
+ )
+
+ await expect(
+ provider.can?.({
+ resource: "user",
+ action: "ban",
+ params: { organizationId: "ignored-for-admin" },
+ identity: { id: "user-1" }
+ })
+ ).resolves.toBe(false)
+ expect(hasPermission).toHaveBeenCalledWith({
+ permissions: { user: ["ban"] }
+ })
+ expect(provider.loginPath).toBe("/login")
+ })
+})
+
+describe("createBetterAuthServerProvider", () => {
+ it("omits server authorization when no mapping is configured", () => {
+ const provider = createBetterAuthServerProvider({
+ api: { getSession: vi.fn().mockResolvedValue(null) }
+ })
+
+ expect(provider.can).toBeUndefined()
+ })
+
+ it("resolves a request identity from headers once per request", async () => {
+ const getSession = vi.fn().mockResolvedValue({
+ user: {
+ id: "user-1",
+ name: "Ada",
+ image: "https://example.com/ada.png"
+ }
+ })
+ const provider = createBetterAuthServerProvider({
+ api: { getSession }
+ })
+ const request = new Request("https://example.com/api/data", {
+ headers: { cookie: "session=token" }
+ })
+
+ const first = provider.getIdentity({
+ headers: request.headers,
+ request
+ })
+ const second = provider.getIdentity({
+ headers: request.headers,
+ request
+ })
+
+ await expect(first).resolves.toEqual({
+ id: "user-1",
+ name: "Ada",
+ image: "https://example.com/ada.png"
+ })
+ await expect(second).resolves.toEqual({
+ id: "user-1",
+ name: "Ada",
+ image: "https://example.com/ada.png"
+ })
+ expect(getSession).toHaveBeenCalledTimes(1)
+ expect(getSession).toHaveBeenCalledWith({ headers: request.headers })
+ })
+
+ it("maps server checks to Better Auth organization permissions", async () => {
+ const hasPermission = vi.fn().mockResolvedValue({ success: true })
+ const provider = createBetterAuthServerProvider(
+ {
+ api: {
+ getSession: vi.fn().mockResolvedValue(null),
+ hasPermission
+ }
+ },
+ { permissionProvider: "organization" }
+ )
+ const headers = new Headers({ cookie: "session=token" })
+
+ await expect(
+ provider.can?.({
+ resource: "member",
+ action: "delete",
+ params: { organizationId: "org-1" },
+ identity: { id: "user-1" },
+ headers
+ })
+ ).resolves.toBe(true)
+ expect(hasPermission).toHaveBeenCalledWith({
+ headers,
+ body: {
+ organizationId: "org-1",
+ permissions: { member: ["delete"] }
+ }
+ })
+ })
+})
diff --git a/src/lib/__tests__/package-metadata.test.ts b/src/lib/__tests__/package-metadata.test.ts
index d9e1d0a00..057695871 100644
--- a/src/lib/__tests__/package-metadata.test.ts
+++ b/src/lib/__tests__/package-metadata.test.ts
@@ -14,6 +14,14 @@ async function readPackageManifest(): Promise {
}
describe("package dependency compatibility", () => {
+ it("exports the client provider factory from the package root", async () => {
+ const entrypoint = await readFile(resolve("src/index.ts"), "utf8")
+
+ expect(entrypoint).toContain(
+ 'export * from "./lib/better-auth-provider"'
+ )
+ })
+
/**
* @see https://github.com/better-stack-ai/better-stack/issues/163
*/
@@ -47,3 +55,26 @@ describe("package dependency compatibility", () => {
expect(manifest.dependencies).not.toHaveProperty("better-call")
})
})
+
+describe("RC publishing", () => {
+ it("can safely publish or retry a prerelease without moving latest", async () => {
+ const workflow = await readFile(
+ resolve(".github/workflows/release.yml"),
+ "utf8"
+ )
+
+ expect(workflow).toContain("workflow_dispatch:")
+ expect(workflow).toContain(
+ "ref: $" +
+ "{{ github.event.release.tag_name || inputs.release_tag }}"
+ )
+ expect(workflow).toContain("npm install -g npm@11.17.0")
+ expect(workflow).toContain("npm publish --access public --provenance")
+ expect(workflow).toContain(
+ 'npm view "@btst/better-auth-ui@$PKG_VERSION"'
+ )
+ expect(workflow).toContain(
+ 'npm view "@btst/better-auth-ui@$NPM_DIST_TAG"'
+ )
+ })
+})
diff --git a/src/lib/__tests__/plugin-context-bridge.test.tsx b/src/lib/__tests__/plugin-context-bridge.test.tsx
index 7396692b1..23f153e54 100644
--- a/src/lib/__tests__/plugin-context-bridge.test.tsx
+++ b/src/lib/__tests__/plugin-context-bridge.test.tsx
@@ -17,7 +17,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
// ─── mocks ────────────────────────────────────────────────────────────────────
vi.mock("@btst/stack/context", () => ({
- usePluginOverrides: vi.fn()
+ useCan: vi.fn(),
+ useIdentity: vi.fn(),
+ useNotify: vi.fn(),
+ usePluginOverrides: vi.fn(),
+ useStack: vi.fn(),
+ useTranslate: vi.fn()
}))
// Capture queryFns passed to useAuthData so we can call them directly in tests.
@@ -51,7 +56,14 @@ vi.mock("../organization-refetcher", () => ({
// ─── imports after mocks ──────────────────────────────────────────────────────
-import { usePluginOverrides } from "@btst/stack/context"
+import {
+ useCan,
+ useIdentity,
+ useNotify,
+ usePluginOverrides,
+ useStack,
+ useTranslate
+} from "@btst/stack/context"
import { AuthUIContext, type AuthUIContextType } from "../auth-ui-provider"
import { BetterAuthPluginProvider } from "../plugin-context-bridge"
@@ -132,6 +144,26 @@ function renderBridge(
beforeEach(() => {
vi.clearAllMocks()
+ vi.mocked(useStack).mockReturnValue({
+ basePath: "/p",
+ overrides: {},
+ router: {}
+ })
+ vi.mocked(useCan).mockReturnValue({ can: true, isPending: false })
+ vi.mocked(useIdentity).mockReturnValue({
+ identity: null,
+ isPending: false,
+ refetch: vi.fn()
+ })
+ vi.mocked(useNotify).mockReturnValue({
+ error: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn()
+ })
+ vi.mocked(useTranslate).mockReturnValue(
+ (_key, defaultValue) => defaultValue
+ )
// biome-ignore lint/suspicious/useIterableCallbackReturn: acceptable use of forEach
Object.keys(capturedQueryFns).forEach((k) => delete capturedQueryFns[k])
})
@@ -555,10 +587,138 @@ describe("hooks — useListTeamMembers uses POST (not GET)", () => {
const ctx = renderBridge()
ctx.hooks.useListUserTeams()
- const qfn = capturedQueryFns["listUserTeams"]
+ const qfn = capturedQueryFns.listUserTeams
expect(qfn).toBeDefined()
qfn()
expect(mockFetch).toHaveBeenCalledWith("/organization/list-user-teams")
})
})
+
+describe("BTST v3 top-level providers", () => {
+ it("routes organization permission hooks through stack can", () => {
+ vi.mocked(useStack).mockReturnValue({
+ auth: { can: vi.fn(), getIdentity: vi.fn() },
+ basePath: "/p",
+ overrides: {},
+ router: {}
+ })
+
+ const context = renderBridge()
+ const permission = context.hooks.useHasPermission({
+ organizationId: "org-1",
+ permissions: { member: ["update"] }
+ })
+
+ expect(permission.data?.success).toBe(true)
+ expect(useCan).toHaveBeenCalledWith({
+ resource: "member",
+ action: "update",
+ params: { organizationId: "org-1" }
+ })
+ })
+
+ it("preserves Better Auth permissions without an explicit stack can mapping", () => {
+ const useHasPermission = vi.fn(() => ({
+ data: { error: null, success: false },
+ isPending: false,
+ isRefetching: false
+ }))
+ vi.mocked(useStack).mockReturnValue({
+ auth: { getIdentity: vi.fn() },
+ basePath: "/p",
+ overrides: {},
+ router: {}
+ })
+
+ const context = renderBridge({ hooks: { useHasPermission } })
+ const permission = context.hooks.useHasPermission({
+ organizationId: "org-1",
+ permissions: { member: ["update"] }
+ })
+
+ expect(permission.data?.success).toBe(false)
+ expect(useHasPermission).toHaveBeenCalledOnce()
+ expect(useCan).not.toHaveBeenCalled()
+ })
+
+ it("fails closed for permission batches that stack cannot represent", () => {
+ vi.mocked(useStack).mockReturnValue({
+ auth: { can: vi.fn(), getIdentity: vi.fn() },
+ basePath: "/p",
+ overrides: {},
+ router: {}
+ })
+
+ const context = renderBridge()
+ const permission = context.hooks.useHasPermission({
+ organizationId: "org-1",
+ permissions: { member: ["update", "delete"] }
+ })
+
+ expect(permission.data?.success).toBe(false)
+ expect(permission.isPending).toBe(false)
+ })
+
+ it("sources navigation and refreshes identity from top-level providers", async () => {
+ const navigate = vi.fn()
+ const refresh = vi.fn()
+ const refetch = vi.fn()
+ const StackLink = () => null
+ vi.mocked(useIdentity).mockReturnValue({
+ identity: null,
+ isPending: false,
+ refetch
+ })
+ vi.mocked(useStack).mockReturnValue({
+ basePath: "/p",
+ overrides: {},
+ router: { Link: StackLink, navigate, refresh }
+ })
+
+ const context = renderBridge({
+ Link: () => null,
+ navigate: vi.fn(),
+ onSessionChange: vi.fn()
+ })
+
+ context.navigate("/account")
+ await context.onSessionChange?.()
+ expect(context.Link).toBe(StackLink)
+ expect(navigate).toHaveBeenCalledWith("/account")
+ expect(refetch).toHaveBeenCalledOnce()
+ expect(refresh).toHaveBeenCalledOnce()
+ })
+
+ it("routes toast rendering through the top-level notify provider", () => {
+ const notify = {
+ error: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn()
+ }
+ vi.mocked(useNotify).mockReturnValue(notify)
+
+ const context = renderBridge({ toast: vi.fn() })
+ context.toast({ variant: "success", message: "Saved" })
+ context.toast({ message: "Heads up" })
+
+ expect(notify.success).toHaveBeenCalledWith("Saved")
+ expect(notify.info).toHaveBeenCalledWith("Heads up")
+ })
+
+ it("translates existing localization values through stack i18n", () => {
+ const translate = vi.fn((key: string, defaultValue: string) =>
+ key === "better-auth-ui.SIGN_IN" ? "Entrar" : defaultValue
+ )
+ vi.mocked(useTranslate).mockReturnValue(translate)
+
+ const context = renderBridge()
+
+ expect(context.localization.SIGN_IN).toBe("Entrar")
+ expect(translate).toHaveBeenCalledWith(
+ "better-auth-ui.SIGN_IN",
+ expect.any(String)
+ )
+ })
+})
diff --git a/src/lib/better-auth-provider-shared.ts b/src/lib/better-auth-provider-shared.ts
new file mode 100644
index 000000000..b90364a04
--- /dev/null
+++ b/src/lib/better-auth-provider-shared.ts
@@ -0,0 +1,58 @@
+import type { CanParams, StackIdentity } from "@btst/stack/context"
+
+export type BetterAuthPermissionProvider = "admin" | "organization"
+
+export interface BetterAuthUser {
+ id: string
+ name?: string | null
+ email?: string | null
+ image?: string | null
+ [key: string]: unknown
+}
+
+export type BetterAuthPermissionResult =
+ | boolean
+ | {
+ data?: { success?: boolean } | null
+ success?: boolean
+ }
+
+export function toStackIdentity(
+ user: BetterAuthUser | null | undefined
+): StackIdentity | null {
+ if (!user) return null
+
+ const { id, name, email, image, ...fields } = user
+
+ return {
+ ...fields,
+ id,
+ ...(typeof name === "string" ? { name } : {}),
+ ...(typeof email === "string" ? { email } : {}),
+ ...(typeof image === "string" ? { image } : {})
+ }
+}
+
+export function getPermissionBody(
+ { resource, action, params }: CanParams,
+ includeOrganizationId = true
+): {
+ organizationId?: string
+ permissions: Record
+} {
+ const organizationId = params?.organizationId
+
+ return {
+ ...(includeOrganizationId && typeof organizationId === "string"
+ ? { organizationId }
+ : {}),
+ permissions: { [resource]: [action] }
+ }
+}
+
+export function getPermissionDecision(
+ result: BetterAuthPermissionResult
+): boolean {
+ if (typeof result === "boolean") return result
+ return result.data?.success ?? result.success ?? false
+}
diff --git a/src/lib/better-auth-provider.ts b/src/lib/better-auth-provider.ts
new file mode 100644
index 000000000..57532c27a
--- /dev/null
+++ b/src/lib/better-auth-provider.ts
@@ -0,0 +1,78 @@
+import type { StackAuthProvider } from "@btst/stack/context"
+import {
+ type BetterAuthPermissionProvider,
+ type BetterAuthPermissionResult,
+ type BetterAuthUser,
+ getPermissionBody,
+ getPermissionDecision,
+ toStackIdentity
+} from "./better-auth-provider-shared"
+
+type MaybePromise = Promise | T
+
+interface BetterAuthClientSessionResult {
+ data?: { user: BetterAuthUser } | null
+}
+
+interface BetterAuthClientPermissionApi {
+ hasPermission: (
+ input: ReturnType
+ ) => MaybePromise
+}
+
+export interface BetterAuthStackClient {
+ getSession: () => MaybePromise
+ admin?: BetterAuthClientPermissionApi
+ organization?: BetterAuthClientPermissionApi
+}
+
+export interface BetterAuthProviderOptions {
+ /** Path used by BTST when an unauthenticated user reaches a gated route. */
+ loginPath?: string
+ /**
+ * Better Auth permission plugin to use for BTST resource/action checks.
+ * Leave unset when the client has no permission plugin configured.
+ */
+ permissionProvider?: BetterAuthPermissionProvider
+ /** Override the default Better Auth permission mapping. */
+ can?: NonNullable
+}
+
+/**
+ * Adapt a Better Auth client to the auth contract consumed by StackProvider.
+ */
+export function createBetterAuthProvider(
+ authClient: BetterAuthStackClient,
+ options: BetterAuthProviderOptions = {}
+): StackAuthProvider {
+ const permissionProvider = options.permissionProvider
+ const can =
+ options.can ??
+ (permissionProvider
+ ? async (
+ params: Parameters>[0]
+ ) => {
+ if (!params.identity) return false
+
+ const permissionApi = authClient[permissionProvider]
+ if (!permissionApi?.hasPermission) return false
+
+ const result = await permissionApi.hasPermission(
+ getPermissionBody(
+ params,
+ permissionProvider === "organization"
+ )
+ )
+ return getPermissionDecision(result)
+ }
+ : undefined)
+
+ return {
+ getIdentity: async () => {
+ const session = await authClient.getSession()
+ return toStackIdentity(session.data?.user)
+ },
+ ...(can ? { can } : {}),
+ loginPath: options.loginPath ?? "/auth/sign-in"
+ }
+}
diff --git a/src/lib/better-auth-server-provider.ts b/src/lib/better-auth-server-provider.ts
new file mode 100644
index 000000000..9ce327a59
--- /dev/null
+++ b/src/lib/better-auth-server-provider.ts
@@ -0,0 +1,104 @@
+import type { StackServerAuthProvider } from "@btst/stack/api"
+import {
+ type BetterAuthPermissionProvider,
+ type BetterAuthPermissionResult,
+ type BetterAuthUser,
+ getPermissionBody,
+ getPermissionDecision,
+ toStackIdentity
+} from "./better-auth-provider-shared"
+
+type MaybePromise = Promise | T
+
+type BetterAuthServerSession =
+ | { data?: { user: BetterAuthUser } | null }
+ | { user: BetterAuthUser }
+ | null
+
+interface BetterAuthServerApi {
+ getSession: (input: {
+ headers: Headers
+ }) => MaybePromise
+ hasPermission?: (input: {
+ headers: Headers
+ body: ReturnType
+ }) => MaybePromise
+ userHasPermission?: (input: {
+ headers: Headers
+ body: ReturnType
+ }) => MaybePromise
+}
+
+export interface BetterAuthStackServer {
+ api: BetterAuthServerApi
+}
+
+export interface BetterAuthServerProviderOptions {
+ /** Better Auth permission plugin used by server-side BTST checks. */
+ permissionProvider?: BetterAuthPermissionProvider
+ /** Override the default Better Auth permission mapping. */
+ can?: NonNullable
+}
+
+function getSessionUser(
+ session: BetterAuthServerSession
+): BetterAuthUser | null {
+ if (!session) return null
+ if ("user" in session) return session.user
+ return session.data?.user ?? null
+}
+
+/**
+ * Adapt a Better Auth server instance to the auth contract consumed by stack().
+ */
+export function createBetterAuthServerProvider(
+ auth: BetterAuthStackServer,
+ options: BetterAuthServerProviderOptions = {}
+): StackServerAuthProvider {
+ const identities = new WeakMap<
+ Request,
+ Promise>
+ >()
+ const permissionProvider = options.permissionProvider
+ const can =
+ options.can ??
+ (permissionProvider
+ ? async (
+ params: Parameters<
+ NonNullable
+ >[0]
+ ) => {
+ if (!params.identity) return false
+
+ const permissionMethod =
+ permissionProvider === "organization"
+ ? auth.api.hasPermission
+ : auth.api.userHasPermission
+ if (!permissionMethod) return false
+
+ const result = await permissionMethod({
+ headers: params.headers,
+ body: getPermissionBody(
+ params,
+ permissionProvider === "organization"
+ )
+ })
+ return getPermissionDecision(result)
+ }
+ : undefined)
+
+ return {
+ getIdentity: ({ headers, request }) => {
+ let identity = identities.get(request)
+
+ if (!identity) {
+ identity = Promise.resolve(
+ auth.api.getSession({ headers })
+ ).then((session) => toStackIdentity(getSessionUser(session)))
+ identities.set(request, identity)
+ }
+ return identity
+ },
+ ...(can ? { can } : {})
+ }
+}
diff --git a/src/lib/plugin-context-bridge.tsx b/src/lib/plugin-context-bridge.tsx
index 6dbc6789f..c2891890a 100644
--- a/src/lib/plugin-context-bridge.tsx
+++ b/src/lib/plugin-context-bridge.tsx
@@ -1,11 +1,20 @@
"use client"
-import { usePluginOverrides } from "@btst/stack/context"
+import {
+ useCan,
+ useIdentity,
+ useNotify,
+ usePluginOverrides,
+ useStack,
+ useTranslate
+} from "@btst/stack/context"
import { type ReactNode, useMemo } from "react"
-import { toast } from "sonner"
import { RecaptchaV3 } from "../components/captcha/recaptcha-v3"
import { useAuthData } from "../hooks/use-auth-data"
-import { authLocalization } from "../localization/auth-localization"
+import {
+ type AuthLocalization,
+ authLocalization
+} from "../localization/auth-localization"
import type { AccountPluginOverrides } from "../plugins/account-plugin"
import type { AuthPluginOverrides } from "../plugins/auth-plugin"
import type { OrganizationPluginOverrides } from "../plugins/organization-plugin"
@@ -19,12 +28,12 @@ import type { DeleteUserOptions } from "../types/delete-user-options"
import type { GenericOAuthOptions } from "../types/generic-oauth-options"
import type { Link } from "../types/link"
import type { OrganizationOptionsContext } from "../types/organization-options"
-import type { RenderToast } from "../types/render-toast"
import type { SignUpOptions } from "../types/sign-up-options"
import type { SocialOptions } from "../types/social-options"
import type { TeamOptionsContext } from "../types/team-options"
import { AuthUIContext, type AuthUIContextType } from "./auth-ui-provider"
import { OrganizationRefetcher } from "./organization-refetcher"
+import { createRenderToast } from "./stack-adapters"
import {
accountViewPaths,
authViewPaths,
@@ -45,11 +54,32 @@ const defaultReplace = (href: string) => {
window.location.replace(href)
}
-const defaultToast: RenderToast = ({ variant = "default", message }) => {
- if (variant === "default") {
- toast(message)
- } else {
- toast[variant](message)
+function useStackHasPermission(
+ params: Parameters[0]
+): ReturnType {
+ const permissions = (
+ "permissions" in params ? params.permissions : params.permission
+ ) as Record
+ const permissionEntries = Object.entries(permissions)
+ const [resource, actions] = permissionEntries[0] ?? []
+ const action = actions?.[0]
+ const isSinglePermission =
+ permissionEntries.length === 1 && actions?.length === 1
+ const organizationId =
+ "organizationId" in params ? params.organizationId : undefined
+ const { can, isPending } = useCan({
+ resource: resource ?? "",
+ action: action ?? "",
+ params: organizationId ? { organizationId } : undefined
+ })
+
+ return {
+ data: {
+ error: null,
+ success: Boolean(isSinglePermission && resource && action && can)
+ },
+ isPending: isSinglePermission && isPending,
+ isRefetching: isSinglePermission && isPending
}
}
@@ -63,6 +93,11 @@ export function BetterAuthPluginProvider({
}: {
children: ReactNode
}) {
+ const { auth, router } = useStack()
+ const { refetch: refetchIdentity } = useIdentity()
+ const notify = useNotify()
+ const translate = useTranslate()
+
// Read auth plugin overrides
const authOverrides = usePluginOverrides<
AuthPluginOverrides,
@@ -73,11 +108,7 @@ export function BetterAuthPluginProvider({
redirectTo: "/",
freshAge: 60 * 60 * 24,
changeEmail: true,
- nameRequired: true,
- Link: DefaultLink,
- navigate: defaultNavigate,
- replace: defaultReplace,
- toast: defaultToast
+ nameRequired: true
})
// Read account plugin overrides (returns defaults if plugin not registered)
@@ -444,12 +475,33 @@ export function BetterAuthPluginProvider({
}, [authOverrides.viewPaths])
const localization = useMemo(() => {
- return { ...authLocalization, ...authOverrides.localization }
- }, [authOverrides.localization])
+ const merged = { ...authLocalization, ...authOverrides.localization }
+
+ return Object.fromEntries(
+ Object.entries(merged).map(([key, defaultValue]) => [
+ key,
+ translate(`better-auth-ui.${key}`, defaultValue)
+ ])
+ ) as AuthLocalization
+ }, [authOverrides.localization, translate])
+
+ const renderToast = useMemo(() => createRenderToast(notify), [notify])
const hooks = useMemo(() => {
- return { ...defaultHooks, ...authOverrides.hooks }
- }, [defaultHooks, authOverrides.hooks])
+ return {
+ ...defaultHooks,
+ ...authOverrides.hooks,
+ ...(auth?.can ? { useHasPermission: useStackHasPermission } : {})
+ }
+ }, [auth, defaultHooks, authOverrides.hooks])
+
+ const onSessionChange = useMemo(
+ () => async () => {
+ await refetchIdentity()
+ await router?.refresh?.()
+ },
+ [refetchIdentity, router?.refresh]
+ )
const mutators = useMemo(() => {
return { ...defaultMutators, ...authOverrides.mutators }
@@ -504,13 +556,11 @@ export function BetterAuthPluginProvider({
account,
signUp,
social,
- // Shared navigation — account/org can legitimately override these via ...authConfig
- toast: authOverrides.toast || defaultToast,
- navigate: authOverrides.navigate || defaultNavigate,
- replace:
- authOverrides.replace || authOverrides.navigate || defaultReplace,
+ toast: renderToast,
+ navigate: router?.navigate || defaultNavigate,
+ replace: router?.navigate || defaultReplace,
viewPaths,
- Link: authOverrides.Link || DefaultLink,
+ Link: (router?.Link as typeof DefaultLink | undefined) || DefaultLink,
apiKey: authOverrides.apiKey,
gravatar: authOverrides.gravatar,
additionalFields: authOverrides.additionalFields,
@@ -524,7 +574,7 @@ export function BetterAuthPluginProvider({
localizeErrors: authOverrides.localizeErrors ?? true,
persistClient: authOverrides.persistClient,
optimistic: authOverrides.optimistic,
- onSessionChange: authOverrides.onSessionChange
+ onSessionChange
}
return (
diff --git a/src/lib/stack-adapters.ts b/src/lib/stack-adapters.ts
new file mode 100644
index 000000000..bae8296cf
--- /dev/null
+++ b/src/lib/stack-adapters.ts
@@ -0,0 +1,15 @@
+import type { StackNotifyProvider } from "@btst/stack/context"
+import type { RenderToast } from "../types/render-toast"
+
+export function createRenderToast(
+ notify: Required
+): RenderToast {
+ return ({ variant = "default", message = "" }) => {
+ if (variant === "default") {
+ notify.info(message)
+ return
+ }
+
+ notify[variant](message)
+ }
+}
diff --git a/src/plugins/auth-plugin.ts b/src/plugins/auth-plugin.ts
index 239126f31..e3ad0cb04 100644
--- a/src/plugins/auth-plugin.ts
+++ b/src/plugins/auth-plugin.ts
@@ -18,8 +18,6 @@ import type { CaptchaOptions } from "../types/captcha-options"
import type { CredentialsOptions } from "../types/credentials-options"
import type { GenericOAuthOptions } from "../types/generic-oauth-options"
import type { GravatarOptions } from "../types/gravatar-options"
-import type { Link } from "../types/link"
-import type { RenderToast } from "../types/render-toast"
import type { SignUpOptions } from "../types/sign-up-options"
import type { SocialOptions } from "../types/social-options"
@@ -56,26 +54,6 @@ export interface AuthPluginOverrides {
* @remarks AuthClient
*/
authClient: AnyAuthClient
- /**
- * Custom Link component for navigation
- * @default
- */
- Link?: Link
- /**
- * Navigate to a new URL
- * @default window.location.href
- */
- navigate?: (href: string) => void
- /**
- * Replace the current URL
- * @default navigate
- */
- replace?: (href: string) => void
- /**
- * Render custom Toasts
- * @default Sonner
- */
- toast?: RenderToast
/**
* Customize the Localization strings
*/
@@ -212,10 +190,6 @@ export interface AuthPluginOverrides {
* ADVANCED: Custom mutators for updating auth data
*/
mutators?: Partial
- /**
- * Called whenever the Session changes
- */
- onSessionChange?: () => void | Promise
/**
* Customize the paths for the auth views
*/
diff --git a/src/server.ts b/src/server.ts
index 0279cfc79..454be45fb 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,4 +1,12 @@
export * from "./components/email/email-template"
+export type {
+ BetterAuthServerProviderOptions,
+ BetterAuthStackServer
+} from "./lib/better-auth-server-provider"
+export {
+ createBetterAuthServerProvider,
+ createBetterAuthServerProvider as createBetterAuthProvider
+} from "./lib/better-auth-server-provider"
export { getViewByPath } from "./lib/utils"
export * from "./lib/view-paths"
export * from "./localization/auth-localization"