mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
feat: add Effect/Platform native integration package
- Add @better-auth/effect package with Effect-native auth operations - All auth operations return Effects with typed errors - HttpApi middleware integration (AuthMiddleware, SessionMiddleware, etc.) - Session context tags (CurrentUser, CurrentSession, AuthSession) - Typed error classes with HTTP status codes - Documentation for Effect/Platform integration Closes #7338
This commit is contained in:
@@ -0,0 +1,913 @@
|
||||
# Effect/Platform Native Integration Plan for Better-Auth
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan outlines creating an **Effect-native** integration for Better-Auth with Effect/Platform. The goal is to allow users to use Better-Auth in a fully Effect-native way - where all auth operations return `Effect`s, use Effect's dependency injection (Context/Layer), and integrate seamlessly with Effect/Platform's HttpApi builder.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- **Issue #7234**: Drizzle Adapter breaks with Effect-based execution model
|
||||
- Maintainers (@bytaesu, @ping-maxwell) are considering Effect support
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### New Package: `@better-auth/effect`
|
||||
|
||||
A dedicated package that provides:
|
||||
|
||||
1. **Effect-wrapped Auth API** - All operations return `Effect`s with typed errors
|
||||
2. **Service Tags** - `BetterAuth`, `CurrentUser`, `CurrentSession` for DI
|
||||
3. **HttpApi Integration** - Middleware, security, and endpoint helpers
|
||||
4. **Error Types** - Effect Schema-based error types
|
||||
|
||||
```
|
||||
packages/
|
||||
├── effect/ # New package: @better-auth/effect
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── BetterAuth.ts # Main service
|
||||
│ │ ├── Session.ts # Session context
|
||||
│ │ ├── Errors.ts # Typed errors
|
||||
│ │ ├── HttpApi.ts # HttpApi integration
|
||||
│ │ └── Middleware.ts # Auth middleware
|
||||
│ ├── package.json
|
||||
│ └── tsconfig.json
|
||||
```
|
||||
|
||||
## Detailed Implementation
|
||||
|
||||
### 1. Error Types (`Errors.ts`)
|
||||
|
||||
```typescript
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiSchema } from "@effect/platform"
|
||||
|
||||
// Base auth error
|
||||
export class AuthError extends Schema.TaggedError<AuthError>()(
|
||||
"AuthError",
|
||||
{
|
||||
message: Schema.String,
|
||||
code: Schema.String,
|
||||
}
|
||||
) {}
|
||||
|
||||
// Unauthorized - 401
|
||||
export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
|
||||
"Unauthorized",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 401 })
|
||||
) {}
|
||||
|
||||
// Forbidden - 403
|
||||
export class Forbidden extends Schema.TaggedError<Forbidden>()(
|
||||
"Forbidden",
|
||||
{
|
||||
message: Schema.String,
|
||||
code: Schema.optional(Schema.String),
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 403 })
|
||||
) {}
|
||||
|
||||
// Session expired - 401
|
||||
export class SessionExpired extends Schema.TaggedError<SessionExpired>()(
|
||||
"SessionExpired",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 401 })
|
||||
) {}
|
||||
|
||||
// Invalid credentials - 401
|
||||
export class InvalidCredentials extends Schema.TaggedError<InvalidCredentials>()(
|
||||
"InvalidCredentials",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 401 })
|
||||
) {}
|
||||
|
||||
// User not found - 404
|
||||
export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
||||
"UserNotFound",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 404 })
|
||||
) {}
|
||||
|
||||
// Email already exists - 409
|
||||
export class EmailAlreadyExists extends Schema.TaggedError<EmailAlreadyExists>()(
|
||||
"EmailAlreadyExists",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 409 })
|
||||
) {}
|
||||
|
||||
// Rate limited - 429
|
||||
export class RateLimited extends Schema.TaggedError<RateLimited>()(
|
||||
"RateLimited",
|
||||
{
|
||||
message: Schema.String,
|
||||
retryAfter: Schema.optional(Schema.Number),
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 429 })
|
||||
) {}
|
||||
```
|
||||
|
||||
### 2. Session Context (`Session.ts`)
|
||||
|
||||
```typescript
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { Auth } from "better-auth"
|
||||
|
||||
// Infer types from auth instance
|
||||
export type InferUser<A extends Auth<any>> = A["$Infer"]["Session"]["user"]
|
||||
export type InferSession<A extends Auth<any>> = A["$Infer"]["Session"]["session"]
|
||||
|
||||
// Create typed session context for a specific auth instance
|
||||
export const makeSessionContext = <A extends Auth<any>>() => {
|
||||
class CurrentUser extends Context.Tag("@better-auth/effect/CurrentUser")<
|
||||
CurrentUser,
|
||||
InferUser<A>
|
||||
>() {}
|
||||
|
||||
class CurrentSession extends Context.Tag("@better-auth/effect/CurrentSession")<
|
||||
CurrentSession,
|
||||
InferSession<A>
|
||||
>() {}
|
||||
|
||||
// Combined session data
|
||||
class AuthSession extends Context.Tag("@better-auth/effect/AuthSession")<
|
||||
AuthSession,
|
||||
{
|
||||
readonly user: InferUser<A>
|
||||
readonly session: InferSession<A>
|
||||
}
|
||||
>() {}
|
||||
|
||||
return { CurrentUser, CurrentSession, AuthSession }
|
||||
}
|
||||
|
||||
// Default exports for simple usage
|
||||
export class CurrentUser extends Context.Tag("@better-auth/effect/CurrentUser")<
|
||||
CurrentUser,
|
||||
Record<string, any>
|
||||
>() {}
|
||||
|
||||
export class CurrentSession extends Context.Tag("@better-auth/effect/CurrentSession")<
|
||||
CurrentSession,
|
||||
Record<string, any>
|
||||
>() {}
|
||||
```
|
||||
|
||||
### 3. Main BetterAuth Service (`BetterAuth.ts`)
|
||||
|
||||
```typescript
|
||||
import { Context, Effect, Layer, pipe } from "effect"
|
||||
import type { Auth, BetterAuthOptions } from "better-auth"
|
||||
import { betterAuth } from "better-auth"
|
||||
import * as Errors from "./Errors"
|
||||
|
||||
// The BetterAuth service interface
|
||||
export interface BetterAuthService<A extends Auth<any> = Auth<any>> {
|
||||
readonly auth: A
|
||||
|
||||
// Session operations
|
||||
readonly getSession: (
|
||||
headers: Headers
|
||||
) => Effect.Effect<
|
||||
{ user: A["$Infer"]["Session"]["user"]; session: A["$Infer"]["Session"]["session"] },
|
||||
Errors.Unauthorized
|
||||
>
|
||||
|
||||
// Sign in
|
||||
readonly signInEmail: (params: {
|
||||
email: string
|
||||
password: string
|
||||
rememberMe?: boolean
|
||||
}) => Effect.Effect<
|
||||
{ user: A["$Infer"]["Session"]["user"]; session: A["$Infer"]["Session"]["session"] },
|
||||
Errors.InvalidCredentials | Errors.UserNotFound | Errors.RateLimited
|
||||
>
|
||||
|
||||
// Sign up
|
||||
readonly signUpEmail: (params: {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
}) => Effect.Effect<
|
||||
{ user: A["$Infer"]["Session"]["user"]; session: A["$Infer"]["Session"]["session"] },
|
||||
Errors.EmailAlreadyExists | Errors.RateLimited
|
||||
>
|
||||
|
||||
// Sign out
|
||||
readonly signOut: (
|
||||
headers: Headers
|
||||
) => Effect.Effect<void, Errors.Unauthorized>
|
||||
|
||||
// List sessions
|
||||
readonly listSessions: (
|
||||
headers: Headers
|
||||
) => Effect.Effect<
|
||||
Array<A["$Infer"]["Session"]["session"]>,
|
||||
Errors.Unauthorized
|
||||
>
|
||||
|
||||
// Revoke session
|
||||
readonly revokeSession: (
|
||||
token: string,
|
||||
headers: Headers
|
||||
) => Effect.Effect<void, Errors.Unauthorized>
|
||||
|
||||
// Handler for mounting
|
||||
readonly handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
|
||||
// Service Tag
|
||||
export class BetterAuth extends Context.Tag("@better-auth/effect/BetterAuth")<
|
||||
BetterAuth,
|
||||
BetterAuthService
|
||||
>() {}
|
||||
|
||||
// Create the service implementation
|
||||
export const make = <Options extends BetterAuthOptions>(
|
||||
options: Options
|
||||
): Effect.Effect<BetterAuthService<Auth<Options>>> =>
|
||||
Effect.sync(() => {
|
||||
const auth = betterAuth(options)
|
||||
|
||||
const mapApiError = (error: any) => {
|
||||
const code = error?.code || error?.body?.code || "UNKNOWN"
|
||||
const message = error?.message || error?.body?.message || "An error occurred"
|
||||
|
||||
switch (code) {
|
||||
case "UNAUTHORIZED":
|
||||
case "INVALID_SESSION":
|
||||
return new Errors.Unauthorized({ message })
|
||||
case "INVALID_CREDENTIALS":
|
||||
return new Errors.InvalidCredentials({ message })
|
||||
case "USER_NOT_FOUND":
|
||||
return new Errors.UserNotFound({ message })
|
||||
case "EMAIL_ALREADY_EXISTS":
|
||||
case "USER_ALREADY_EXISTS":
|
||||
return new Errors.EmailAlreadyExists({ message })
|
||||
case "RATE_LIMIT_EXCEEDED":
|
||||
return new Errors.RateLimited({ message })
|
||||
case "SESSION_EXPIRED":
|
||||
return new Errors.SessionExpired({ message })
|
||||
case "FORBIDDEN":
|
||||
return new Errors.Forbidden({ message, code })
|
||||
default:
|
||||
return new Errors.AuthError({ message, code })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
auth,
|
||||
|
||||
getSession: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.getSession({ headers }),
|
||||
catch: mapApiError,
|
||||
}).pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session
|
||||
? Effect.succeed(session)
|
||||
: Effect.fail(new Errors.Unauthorized({ message: "No session found" }))
|
||||
)
|
||||
),
|
||||
|
||||
signInEmail: (params) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth.api.signInEmail({
|
||||
body: params,
|
||||
}),
|
||||
catch: mapApiError,
|
||||
}),
|
||||
|
||||
signUpEmail: (params) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth.api.signUpEmail({
|
||||
body: params,
|
||||
}),
|
||||
catch: mapApiError,
|
||||
}),
|
||||
|
||||
signOut: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.signOut({ headers }),
|
||||
catch: mapApiError,
|
||||
}).pipe(Effect.asVoid),
|
||||
|
||||
listSessions: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.listSessions({ headers }),
|
||||
catch: mapApiError,
|
||||
}),
|
||||
|
||||
revokeSession: (token: string, headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth.api.revokeSession({
|
||||
headers,
|
||||
body: { token },
|
||||
}),
|
||||
catch: mapApiError,
|
||||
}).pipe(Effect.asVoid),
|
||||
|
||||
handler: auth.handler,
|
||||
} as BetterAuthService<Auth<Options>>
|
||||
})
|
||||
|
||||
// Layer constructor
|
||||
export const layer = <Options extends BetterAuthOptions>(
|
||||
options: Options
|
||||
): Layer.Layer<BetterAuth> =>
|
||||
Layer.effect(BetterAuth, make(options))
|
||||
```
|
||||
|
||||
### 4. HttpApi Integration (`HttpApi.ts`)
|
||||
|
||||
```typescript
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import {
|
||||
HttpApiMiddleware,
|
||||
HttpApiSecurity,
|
||||
HttpServerRequest,
|
||||
HttpApp,
|
||||
HttpRouter,
|
||||
} from "@effect/platform"
|
||||
import { BetterAuth, type BetterAuthService } from "./BetterAuth"
|
||||
import { CurrentUser, CurrentSession } from "./Session"
|
||||
import * as Errors from "./Errors"
|
||||
|
||||
// ============================================================================
|
||||
// Authentication Middleware
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates an authentication middleware for HttpApi
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* class MyApi extends HttpApiGroup.make("api")
|
||||
* .add(...)
|
||||
* .middleware(AuthMiddleware)
|
||||
* {}
|
||||
* ```
|
||||
*/
|
||||
export class AuthMiddleware extends HttpApiMiddleware.Tag<AuthMiddleware>()(
|
||||
"@better-auth/effect/AuthMiddleware",
|
||||
{
|
||||
failure: Errors.Unauthorized,
|
||||
provides: CurrentUser,
|
||||
security: {
|
||||
cookie: HttpApiSecurity.apiKey({
|
||||
in: "cookie",
|
||||
key: "better-auth.session_token",
|
||||
}),
|
||||
},
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Layer that provides the AuthMiddleware implementation
|
||||
*/
|
||||
export const AuthMiddlewareLive: Layer.Layer<AuthMiddleware, never, BetterAuth> =
|
||||
Layer.effect(
|
||||
AuthMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const betterAuth = yield* BetterAuth
|
||||
|
||||
return {
|
||||
cookie: (_token) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const session = yield* betterAuth.getSession(request.headers)
|
||||
return session.user
|
||||
}),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Full Session Middleware (provides both user and session)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Authentication middleware that provides both user and session
|
||||
*/
|
||||
export class SessionMiddleware extends HttpApiMiddleware.Tag<SessionMiddleware>()(
|
||||
"@better-auth/effect/SessionMiddleware",
|
||||
{
|
||||
failure: Errors.Unauthorized,
|
||||
provides: CurrentSession,
|
||||
security: {
|
||||
cookie: HttpApiSecurity.apiKey({
|
||||
in: "cookie",
|
||||
key: "better-auth.session_token",
|
||||
}),
|
||||
},
|
||||
}
|
||||
) {}
|
||||
|
||||
export const SessionMiddlewareLive: Layer.Layer<SessionMiddleware, never, BetterAuth> =
|
||||
Layer.effect(
|
||||
SessionMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const betterAuth = yield* BetterAuth
|
||||
|
||||
return {
|
||||
cookie: (_token) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const session = yield* betterAuth.getSession(request.headers)
|
||||
return session.session
|
||||
}),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Mount Better-Auth Handler
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates an HttpApp from better-auth handler
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const router = HttpRouter.empty.pipe(
|
||||
* HttpRouter.mountApp("/api/auth", yield* betterAuthApp)
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const betterAuthApp: Effect.Effect<
|
||||
HttpApp.Default,
|
||||
never,
|
||||
BetterAuth
|
||||
> = Effect.gen(function* () {
|
||||
const { handler } = yield* BetterAuth
|
||||
return HttpApp.fromWebHandler(handler)
|
||||
})
|
||||
|
||||
/**
|
||||
* Layer that mounts better-auth at a path
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* HttpApiBuilder.Router.use((router) =>
|
||||
* router.mountApp("/api/auth", yield* betterAuthApp)
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const mountBetterAuth = (
|
||||
path: `/${string}` = "/api/auth"
|
||||
): Effect.Effect<void, never, BetterAuth | HttpRouter.HttpRouter.Service<any, any>> =>
|
||||
Effect.gen(function* () {
|
||||
const app = yield* betterAuthApp
|
||||
// This would need to be adapted based on how you're using the router
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Optional Auth Middleware (doesn't fail if not authenticated)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Optional auth - provides user if authenticated, undefined otherwise
|
||||
*/
|
||||
export class OptionalUser extends Context.Tag("@better-auth/effect/OptionalUser")<
|
||||
OptionalUser,
|
||||
Record<string, any> | undefined
|
||||
>() {}
|
||||
|
||||
export class OptionalAuthMiddleware extends HttpApiMiddleware.Tag<OptionalAuthMiddleware>()(
|
||||
"@better-auth/effect/OptionalAuthMiddleware",
|
||||
{
|
||||
provides: OptionalUser,
|
||||
security: {
|
||||
cookie: HttpApiSecurity.apiKey({
|
||||
in: "cookie",
|
||||
key: "better-auth.session_token",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ optional: true }
|
||||
) {}
|
||||
|
||||
export const OptionalAuthMiddlewareLive: Layer.Layer<
|
||||
OptionalAuthMiddleware,
|
||||
never,
|
||||
BetterAuth
|
||||
> = Layer.effect(
|
||||
OptionalAuthMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const betterAuth = yield* BetterAuth
|
||||
|
||||
return {
|
||||
cookie: (_token) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const result = yield* Effect.either(
|
||||
betterAuth.getSession(request.headers)
|
||||
)
|
||||
return result._tag === "Right" ? result.right.user : undefined
|
||||
}),
|
||||
}
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Complete Example Usage
|
||||
|
||||
```typescript
|
||||
// ============================================================================
|
||||
// auth.ts - Configure Better Auth
|
||||
// ============================================================================
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import { db } from "./db"
|
||||
|
||||
export const authConfig = {
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
emailAndPassword: { enabled: true },
|
||||
// ... other config
|
||||
}
|
||||
|
||||
export type AuthConfig = typeof authConfig
|
||||
|
||||
// ============================================================================
|
||||
// auth-effect.ts - Effect-native auth service
|
||||
// ============================================================================
|
||||
import { Layer } from "effect"
|
||||
import { BetterAuth, layer as betterAuthLayer } from "@better-auth/effect"
|
||||
import { authConfig } from "./auth"
|
||||
|
||||
export const BetterAuthLive = betterAuthLayer(authConfig)
|
||||
|
||||
// Re-export for convenience
|
||||
export { BetterAuth } from "@better-auth/effect"
|
||||
export { CurrentUser, CurrentSession } from "@better-auth/effect"
|
||||
export {
|
||||
AuthMiddleware,
|
||||
AuthMiddlewareLive,
|
||||
SessionMiddleware,
|
||||
SessionMiddlewareLive,
|
||||
} from "@better-auth/effect"
|
||||
|
||||
// ============================================================================
|
||||
// api.ts - Define your API with HttpApi
|
||||
// ============================================================================
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiBuilder,
|
||||
HttpApiEndpoint,
|
||||
HttpApiGroup,
|
||||
HttpApiMiddleware,
|
||||
HttpMiddleware,
|
||||
HttpServer,
|
||||
HttpRouter,
|
||||
HttpApp,
|
||||
} from "@effect/platform"
|
||||
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
|
||||
import {
|
||||
BetterAuth,
|
||||
BetterAuthLive,
|
||||
CurrentUser,
|
||||
AuthMiddleware,
|
||||
AuthMiddlewareLive,
|
||||
betterAuthApp,
|
||||
} from "./auth-effect"
|
||||
|
||||
// Define schemas
|
||||
class UserProfile extends Schema.Class<UserProfile>("UserProfile")({
|
||||
id: Schema.String,
|
||||
email: Schema.String,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
class CreatePostInput extends Schema.Class<CreatePostInput>("CreatePostInput")({
|
||||
title: Schema.String,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
class Post extends Schema.Class<Post>("Post")({
|
||||
id: Schema.String,
|
||||
title: Schema.String,
|
||||
content: Schema.String,
|
||||
authorId: Schema.String,
|
||||
}) {}
|
||||
|
||||
// Protected API group - requires authentication
|
||||
class UsersApi extends HttpApiGroup.make("users")
|
||||
.add(
|
||||
HttpApiEndpoint.get("me", "/me")
|
||||
.addSuccess(UserProfile)
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("profile", "/profile/:id")
|
||||
.addSuccess(UserProfile)
|
||||
)
|
||||
.middleware(AuthMiddleware) // All endpoints require auth
|
||||
.prefix("/users")
|
||||
{}
|
||||
|
||||
class PostsApi extends HttpApiGroup.make("posts")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", "/")
|
||||
.addSuccess(Schema.Array(Post))
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("create", "/")
|
||||
.setPayload(CreatePostInput)
|
||||
.addSuccess(Post)
|
||||
.middleware(AuthMiddleware) // Only this endpoint requires auth
|
||||
)
|
||||
.prefix("/posts")
|
||||
{}
|
||||
|
||||
// Combine into full API
|
||||
class MyApi extends HttpApi.make("my-api")
|
||||
.add(UsersApi)
|
||||
.add(PostsApi)
|
||||
{}
|
||||
|
||||
// ============================================================================
|
||||
// handlers.ts - Implement the handlers
|
||||
// ============================================================================
|
||||
|
||||
const UsersHandlers = HttpApiBuilder.group(MyApi, "users", (handlers) =>
|
||||
handlers
|
||||
.handle("me", () =>
|
||||
Effect.gen(function* () {
|
||||
// CurrentUser is automatically available due to AuthMiddleware
|
||||
const user = yield* CurrentUser
|
||||
return new UserProfile({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
})
|
||||
)
|
||||
.handle("profile", ({ path }) =>
|
||||
Effect.gen(function* () {
|
||||
// Fetch user by ID from database
|
||||
// ... your implementation
|
||||
return new UserProfile({
|
||||
id: path.id,
|
||||
email: "user@example.com",
|
||||
name: "User",
|
||||
})
|
||||
})
|
||||
)
|
||||
).pipe(Layer.provide(AuthMiddlewareLive))
|
||||
|
||||
const PostsHandlers = HttpApiBuilder.group(MyApi, "posts", (handlers) =>
|
||||
handlers
|
||||
.handle("list", () =>
|
||||
Effect.gen(function* () {
|
||||
// Public endpoint - no auth required
|
||||
return []
|
||||
})
|
||||
)
|
||||
.handle("create", ({ payload }) =>
|
||||
Effect.gen(function* () {
|
||||
const user = yield* CurrentUser
|
||||
return new Post({
|
||||
id: "new-post-id",
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
authorId: user.id,
|
||||
})
|
||||
})
|
||||
)
|
||||
).pipe(Layer.provide(AuthMiddlewareLive))
|
||||
|
||||
// ============================================================================
|
||||
// server.ts - Start the server
|
||||
// ============================================================================
|
||||
|
||||
const ApiLive = HttpApiBuilder.api(MyApi).pipe(
|
||||
Layer.provide([UsersHandlers, PostsHandlers])
|
||||
)
|
||||
|
||||
// Custom router to mount better-auth
|
||||
const AuthRouterLive = HttpApiBuilder.Router.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const authApp = yield* betterAuthApp
|
||||
yield* router.mountApp("/api/auth", authApp)
|
||||
})
|
||||
)
|
||||
|
||||
// Serve everything
|
||||
HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
|
||||
Layer.provide(AuthRouterLive),
|
||||
Layer.provide(ApiLive),
|
||||
Layer.provide(BetterAuthLive),
|
||||
HttpServer.withLogAddress,
|
||||
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
|
||||
Layer.launch,
|
||||
NodeRuntime.runMain
|
||||
)
|
||||
```
|
||||
|
||||
### 6. Using BetterAuth Service Directly
|
||||
|
||||
```typescript
|
||||
import { Effect } from "effect"
|
||||
import { BetterAuth } from "@better-auth/effect"
|
||||
|
||||
// In an Effect context
|
||||
const program = Effect.gen(function* () {
|
||||
const auth = yield* BetterAuth
|
||||
|
||||
// Sign up a new user
|
||||
const { user, session } = yield* auth.signUpEmail({
|
||||
email: "user@example.com",
|
||||
password: "securepassword",
|
||||
name: "New User",
|
||||
})
|
||||
|
||||
console.log("Created user:", user)
|
||||
|
||||
// Sign in
|
||||
const signInResult = yield* auth.signInEmail({
|
||||
email: "user@example.com",
|
||||
password: "securepassword",
|
||||
})
|
||||
|
||||
// Get session
|
||||
const headers = new Headers()
|
||||
headers.set("cookie", `better-auth.session_token=${session.token}`)
|
||||
|
||||
const currentSession = yield* auth.getSession(headers)
|
||||
console.log("Current session:", currentSession)
|
||||
})
|
||||
|
||||
// Run with layer
|
||||
program.pipe(
|
||||
Effect.provide(BetterAuthLive),
|
||||
Effect.runPromise
|
||||
)
|
||||
```
|
||||
|
||||
## Phase 2: Database Integration with @effect/sql-drizzle
|
||||
|
||||
For users who want full Effect-native database operations:
|
||||
|
||||
```typescript
|
||||
import { PgDrizzle } from "@effect/sql-drizzle/Pg"
|
||||
import { PgClient } from "@effect/sql-pg"
|
||||
import { Layer, Effect } from "effect"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
|
||||
// Create auth with Effect-Drizzle
|
||||
export const makeBetterAuthWithEffectDrizzle = Effect.gen(function* () {
|
||||
const db = yield* PgDrizzle
|
||||
|
||||
return betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
// ... config
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
New documentation page: `docs/content/docs/integrations/effect-platform.mdx`
|
||||
|
||||
### Sections:
|
||||
1. **Overview** - What Effect/Platform is and why use it
|
||||
2. **Installation** - Dependencies and setup
|
||||
3. **Basic Integration** - Simple mounting of handler
|
||||
4. **Effect-Native Usage** - Using the BetterAuth service
|
||||
5. **HttpApi Integration** - Type-safe API with middleware
|
||||
6. **Authentication Patterns** - Protected routes, optional auth
|
||||
7. **Error Handling** - Typed errors and recovery
|
||||
8. **Database Integration** - Using with @effect/sql-drizzle
|
||||
9. **Full Example** - Complete application
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Package (Week 1-2)
|
||||
- [ ] Create `@better-auth/effect` package structure
|
||||
- [ ] Implement error types
|
||||
- [ ] Implement BetterAuth service
|
||||
- [ ] Implement session context tags
|
||||
- [ ] Basic tests
|
||||
|
||||
### Phase 2: HttpApi Integration (Week 2-3)
|
||||
- [ ] Implement AuthMiddleware
|
||||
- [ ] Implement SessionMiddleware
|
||||
- [ ] Implement OptionalAuthMiddleware
|
||||
- [ ] Handler mounting utilities
|
||||
- [ ] Integration tests
|
||||
|
||||
### Phase 3: Documentation (Week 3-4)
|
||||
- [ ] Write integration guide
|
||||
- [ ] Create examples
|
||||
- [ ] Add to docs navigation
|
||||
- [ ] Review and polish
|
||||
|
||||
### Phase 4: Advanced Features (Week 4+)
|
||||
- [ ] @effect/sql-drizzle integration
|
||||
- [ ] OpenAPI integration
|
||||
- [ ] Client generation helpers
|
||||
- [ ] Additional middleware patterns
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
```
|
||||
packages/effect/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── tsdown.config.ts
|
||||
├── src/
|
||||
│ ├── index.ts
|
||||
│ ├── BetterAuth.ts
|
||||
│ ├── Session.ts
|
||||
│ ├── Errors.ts
|
||||
│ ├── HttpApi.ts
|
||||
│ └── Middleware.ts
|
||||
└── test/
|
||||
├── BetterAuth.test.ts
|
||||
└── HttpApi.test.ts
|
||||
|
||||
docs/content/docs/integrations/effect-platform.mdx
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
```
|
||||
pnpm-workspace.yaml # Add effect package
|
||||
packages/better-auth/package.json # Add peer dep mention
|
||||
docs/source.config.ts # Add new doc page
|
||||
```
|
||||
|
||||
## GitHub Issue Template
|
||||
|
||||
```markdown
|
||||
## Feature Request: Effect/Platform Native Integration
|
||||
|
||||
### Summary
|
||||
Add official Effect-native integration for using Better-Auth with Effect/Platform's HttpApi builder.
|
||||
|
||||
### Motivation
|
||||
Effect/Platform is gaining popularity for building type-safe APIs. Users want to use Better-Auth in an Effect-native way where:
|
||||
- All auth operations return `Effect`s with typed errors
|
||||
- Integration with Effect's dependency injection (Context/Layer)
|
||||
- Seamless integration with HttpApi middleware system
|
||||
- Type-safe session access in handlers
|
||||
|
||||
### Related Issues
|
||||
- #7234 (Drizzle Effect-based execution)
|
||||
|
||||
### Proposed Solution
|
||||
Create a new `@better-auth/effect` package that provides:
|
||||
1. Effect-wrapped auth operations
|
||||
2. HttpApiMiddleware for authentication
|
||||
3. Typed error types using Effect Schema
|
||||
4. Session context tags (CurrentUser, CurrentSession)
|
||||
|
||||
### Example Usage
|
||||
```typescript
|
||||
import { HttpApiBuilder, HttpApiGroup, HttpApiEndpoint } from "@effect/platform"
|
||||
import { BetterAuth, AuthMiddleware, CurrentUser } from "@better-auth/effect"
|
||||
|
||||
class ProtectedApi extends HttpApiGroup.make("api")
|
||||
.add(HttpApiEndpoint.get("me", "/me").addSuccess(User))
|
||||
.middleware(AuthMiddleware)
|
||||
{}
|
||||
|
||||
const handler = HttpApiBuilder.group(MyApi, "api", (h) =>
|
||||
h.handle("me", () => CurrentUser) // Direct access to typed user
|
||||
).pipe(Layer.provide(AuthMiddlewareLive))
|
||||
```
|
||||
|
||||
### Additional Context
|
||||
- Builds on existing better-auth architecture
|
||||
- Non-breaking addition (new package)
|
||||
- Aligns with modern TypeScript best practices
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
This plan creates a truly **Effect-native** integration where:
|
||||
|
||||
1. ✅ All auth operations return `Effect`s
|
||||
2. ✅ Typed errors using Effect Schema
|
||||
3. ✅ Dependency injection via Context/Layer
|
||||
4. ✅ Seamless HttpApi middleware integration
|
||||
5. ✅ Type-safe session access in handlers
|
||||
6. ✅ Optional future integration with @effect/sql-drizzle
|
||||
|
||||
The integration follows Effect's patterns and provides a first-class developer experience for Effect/Platform users.
|
||||
@@ -0,0 +1,505 @@
|
||||
---
|
||||
title: Effect/Platform Integration
|
||||
description: Effect-native integration for Better Auth with Effect/Platform's HttpApi builder.
|
||||
---
|
||||
|
||||
[Effect](https://effect.website) is a powerful TypeScript library for building type-safe, composable applications. This guide shows how to use Better Auth with Effect/Platform's HttpApi builder in an **Effect-native** way.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the required packages:
|
||||
|
||||
```bash
|
||||
npm install @better-auth/effect better-auth effect @effect/platform @effect/platform-node
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure Better Auth
|
||||
|
||||
First, set up your Better Auth instance as usual:
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import { db } from "./db"
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
emailAndPassword: { enabled: true },
|
||||
})
|
||||
|
||||
export type Auth = typeof auth
|
||||
```
|
||||
|
||||
### 2. Create the Effect Layer
|
||||
|
||||
Wrap your auth instance in an Effect Layer:
|
||||
|
||||
```ts title="auth-effect.ts"
|
||||
import { layerFromAuth } from "@better-auth/effect"
|
||||
import { auth } from "./auth"
|
||||
|
||||
export const BetterAuthLive = layerFromAuth(auth)
|
||||
```
|
||||
|
||||
### 3. Define Your API
|
||||
|
||||
Use Effect/Platform's HttpApi to define type-safe endpoints:
|
||||
|
||||
```ts title="api.ts"
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiEndpoint,
|
||||
HttpApiGroup,
|
||||
} from "@effect/platform"
|
||||
import { Schema } from "effect"
|
||||
import { AuthMiddleware } from "@better-auth/effect"
|
||||
|
||||
// Define schemas
|
||||
class UserProfile extends Schema.Class<UserProfile>("UserProfile")({
|
||||
id: Schema.String,
|
||||
email: Schema.String,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
// Protected API - requires authentication
|
||||
class UsersApi extends HttpApiGroup.make("users")
|
||||
.add(
|
||||
HttpApiEndpoint.get("me", "/me")
|
||||
.addSuccess(UserProfile)
|
||||
)
|
||||
.middleware(AuthMiddleware)
|
||||
.prefix("/users")
|
||||
{}
|
||||
|
||||
// Public API
|
||||
class PublicApi extends HttpApiGroup.make("public")
|
||||
.add(
|
||||
HttpApiEndpoint.get("health", "/health")
|
||||
.addSuccess(Schema.Struct({ status: Schema.String }))
|
||||
)
|
||||
{}
|
||||
|
||||
// Combine into your API
|
||||
export class MyApi extends HttpApi.make("my-api")
|
||||
.add(UsersApi)
|
||||
.add(PublicApi)
|
||||
{}
|
||||
```
|
||||
|
||||
### 4. Implement Handlers
|
||||
|
||||
```ts title="handlers.ts"
|
||||
import { HttpApiBuilder } from "@effect/platform"
|
||||
import { Effect, Layer } from "effect"
|
||||
import {
|
||||
AuthMiddlewareLive,
|
||||
CurrentUser,
|
||||
} from "@better-auth/effect"
|
||||
import { MyApi } from "./api"
|
||||
|
||||
export const UsersLive = HttpApiBuilder.group(MyApi, "users", (handlers) =>
|
||||
handlers.handle("me", () =>
|
||||
Effect.gen(function* () {
|
||||
// CurrentUser is automatically available via AuthMiddleware
|
||||
const user = yield* CurrentUser
|
||||
return new UserProfile({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
})
|
||||
)
|
||||
).pipe(Layer.provide(AuthMiddlewareLive))
|
||||
|
||||
export const PublicLive = HttpApiBuilder.group(MyApi, "public", (handlers) =>
|
||||
handlers.handle("health", () =>
|
||||
Effect.succeed({ status: "ok" })
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Start the Server
|
||||
|
||||
```ts title="server.ts"
|
||||
import {
|
||||
HttpApiBuilder,
|
||||
HttpMiddleware,
|
||||
HttpServer,
|
||||
} from "@effect/platform"
|
||||
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import { betterAuthApp } from "@better-auth/effect"
|
||||
import { BetterAuthLive } from "./auth-effect"
|
||||
import { MyApi } from "./api"
|
||||
import { UsersLive, PublicLive } from "./handlers"
|
||||
|
||||
// Build API
|
||||
const ApiLive = HttpApiBuilder.api(MyApi).pipe(
|
||||
Layer.provide([UsersLive, PublicLive])
|
||||
)
|
||||
|
||||
// Mount better-auth handler
|
||||
const AuthRouterLive = HttpApiBuilder.Router.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const authApp = yield* betterAuthApp
|
||||
yield* router.mountApp("/api/auth", authApp)
|
||||
})
|
||||
)
|
||||
|
||||
// Serve
|
||||
HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
|
||||
Layer.provide(AuthRouterLive),
|
||||
Layer.provide(ApiLive),
|
||||
Layer.provide(BetterAuthLive),
|
||||
HttpServer.withLogAddress,
|
||||
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
|
||||
Layer.launch,
|
||||
NodeRuntime.runMain
|
||||
)
|
||||
```
|
||||
|
||||
## Authentication Middleware
|
||||
|
||||
The package provides several middleware options for different use cases:
|
||||
|
||||
### `AuthMiddleware` - User Context
|
||||
|
||||
Requires authentication and provides `CurrentUser`:
|
||||
|
||||
```ts
|
||||
import { AuthMiddleware, CurrentUser, AuthMiddlewareLive } from "@better-auth/effect"
|
||||
|
||||
class ProtectedApi extends HttpApiGroup.make("protected")
|
||||
.add(HttpApiEndpoint.get("data", "/data").addSuccess(Data))
|
||||
.middleware(AuthMiddleware)
|
||||
{}
|
||||
|
||||
// In handler
|
||||
const handler = Effect.gen(function* () {
|
||||
const user = yield* CurrentUser
|
||||
return { userId: user.id }
|
||||
})
|
||||
|
||||
// Provide the layer
|
||||
Layer.provide(AuthMiddlewareLive)
|
||||
```
|
||||
|
||||
### `SessionMiddleware` - Session Context
|
||||
|
||||
Requires authentication and provides `CurrentSession`:
|
||||
|
||||
```ts
|
||||
import { SessionMiddleware, CurrentSession, SessionMiddlewareLive } from "@better-auth/effect"
|
||||
|
||||
const handler = Effect.gen(function* () {
|
||||
const session = yield* CurrentSession
|
||||
return {
|
||||
token: session.token,
|
||||
expiresAt: session.expiresAt,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### `FullAuthMiddleware` - Both User and Session
|
||||
|
||||
```ts
|
||||
import { FullAuthMiddleware, AuthSession, FullAuthMiddlewareLive } from "@better-auth/effect"
|
||||
|
||||
const handler = Effect.gen(function* () {
|
||||
const { user, session } = yield* AuthSession
|
||||
return { userId: user.id, sessionId: session.id }
|
||||
})
|
||||
```
|
||||
|
||||
### `OptionalAuthMiddleware` - Optional Authentication
|
||||
|
||||
For routes where auth is optional:
|
||||
|
||||
```ts
|
||||
import { OptionalAuthMiddleware, OptionalUser, OptionalAuthMiddlewareLive } from "@better-auth/effect"
|
||||
|
||||
class PublicApi extends HttpApiGroup.make("public")
|
||||
.add(HttpApiEndpoint.get("greeting", "/greeting").addSuccess(Greeting))
|
||||
.middleware(OptionalAuthMiddleware)
|
||||
{}
|
||||
|
||||
const handler = Effect.gen(function* () {
|
||||
const maybeUser = yield* OptionalUser
|
||||
if (maybeUser) {
|
||||
return { message: `Hello, ${maybeUser.name}!` }
|
||||
}
|
||||
return { message: "Hello, guest!" }
|
||||
})
|
||||
```
|
||||
|
||||
### Combined Middleware Layer
|
||||
|
||||
Use `AllAuthMiddlewareLive` to provide all middleware at once:
|
||||
|
||||
```ts
|
||||
import { AllAuthMiddlewareLive } from "@better-auth/effect"
|
||||
|
||||
const ApiLive = HttpApiBuilder.api(MyApi).pipe(
|
||||
Layer.provide(AllAuthMiddlewareLive),
|
||||
Layer.provide(BetterAuthLive)
|
||||
)
|
||||
```
|
||||
|
||||
## Using the BetterAuth Service
|
||||
|
||||
You can also use the `BetterAuth` service directly for programmatic auth operations:
|
||||
|
||||
```ts
|
||||
import { BetterAuth } from "@better-auth/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const auth = yield* BetterAuth
|
||||
|
||||
// Sign up a new user
|
||||
const { user, session } = yield* auth.signUpEmail({
|
||||
email: "newuser@example.com",
|
||||
password: "securepassword",
|
||||
name: "New User",
|
||||
})
|
||||
|
||||
console.log("Created user:", user.id)
|
||||
|
||||
// Sign in
|
||||
const signInResult = yield* auth.signInEmail({
|
||||
email: "user@example.com",
|
||||
password: "password",
|
||||
})
|
||||
|
||||
// List sessions
|
||||
const sessions = yield* auth.listSessions(headers)
|
||||
|
||||
// Revoke a session
|
||||
yield* auth.revokeSession(sessionToken, headers)
|
||||
|
||||
// Sign out
|
||||
yield* auth.signOut(headers)
|
||||
})
|
||||
|
||||
// Run with the layer
|
||||
program.pipe(
|
||||
Effect.provide(BetterAuthLive),
|
||||
Effect.runPromise
|
||||
)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are typed Effect Schema `TaggedError` classes with proper HTTP status codes:
|
||||
|
||||
```ts
|
||||
import {
|
||||
Unauthorized, // 401 - No valid session
|
||||
InvalidCredentials, // 401 - Wrong email/password
|
||||
SessionExpired, // 401 - Session has expired
|
||||
Forbidden, // 403 - Lacks permission
|
||||
UserNotFound, // 404 - User doesn't exist
|
||||
EmailAlreadyExists, // 409 - Email taken on signup
|
||||
RateLimited, // 429 - Too many requests
|
||||
} from "@better-auth/effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const auth = yield* BetterAuth
|
||||
return yield* auth.signInEmail({
|
||||
email: "user@example.com",
|
||||
password: "password",
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchTag("InvalidCredentials", (error) =>
|
||||
Effect.succeed({ error: "Wrong email or password" })
|
||||
),
|
||||
Effect.catchTag("RateLimited", (error) =>
|
||||
Effect.succeed({ error: `Too many attempts. Try again in ${error.retryAfter}s` })
|
||||
),
|
||||
Effect.catchTag("UserNotFound", (error) =>
|
||||
Effect.succeed({ error: "No account found with this email" })
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Typed Session Context
|
||||
|
||||
For exact type inference from your auth config:
|
||||
|
||||
```ts
|
||||
import { makeSessionContext } from "@better-auth/effect"
|
||||
import type { auth } from "./auth"
|
||||
|
||||
// Create typed context tags
|
||||
const { CurrentUser, CurrentSession, AuthSession } = makeSessionContext<typeof auth>()
|
||||
|
||||
// Now CurrentUser.Type is exactly your user type with all custom fields
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
Use Effect/Platform's CORS middleware:
|
||||
|
||||
```ts
|
||||
import { HttpApiBuilder } from "@effect/platform"
|
||||
|
||||
HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
|
||||
Layer.provide(HttpApiBuilder.middlewareCors({
|
||||
allowedOrigins: ["http://localhost:3001"],
|
||||
allowedMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
})),
|
||||
// ... rest of layers
|
||||
)
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
Here's a complete example with everything put together:
|
||||
|
||||
```ts title="main.ts"
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiBuilder,
|
||||
HttpApiEndpoint,
|
||||
HttpApiGroup,
|
||||
HttpMiddleware,
|
||||
HttpServer,
|
||||
} from "@effect/platform"
|
||||
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import {
|
||||
layerFromAuth,
|
||||
AuthMiddleware,
|
||||
AuthMiddlewareLive,
|
||||
SessionMiddleware,
|
||||
SessionMiddlewareLive,
|
||||
OptionalAuthMiddleware,
|
||||
OptionalAuthMiddlewareLive,
|
||||
CurrentUser,
|
||||
CurrentSession,
|
||||
OptionalUser,
|
||||
betterAuthApp,
|
||||
} from "@better-auth/effect"
|
||||
import { db } from "./db"
|
||||
|
||||
// 1. Configure auth
|
||||
const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
emailAndPassword: { enabled: true },
|
||||
})
|
||||
|
||||
const BetterAuthLive = layerFromAuth(auth)
|
||||
|
||||
// 2. Define schemas
|
||||
class UserProfile extends Schema.Class<UserProfile>("UserProfile")({
|
||||
id: Schema.String,
|
||||
email: Schema.String,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
class SessionInfo extends Schema.Class<SessionInfo>("SessionInfo")({
|
||||
id: Schema.String,
|
||||
expiresAt: Schema.Date,
|
||||
}) {}
|
||||
|
||||
class Greeting extends Schema.Class<Greeting>("Greeting")({
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
// 3. Define API
|
||||
class UsersApi extends HttpApiGroup.make("users")
|
||||
.add(HttpApiEndpoint.get("me", "/me").addSuccess(UserProfile))
|
||||
.add(HttpApiEndpoint.get("session", "/session").addSuccess(SessionInfo))
|
||||
.middleware(AuthMiddleware)
|
||||
.prefix("/users")
|
||||
{}
|
||||
|
||||
class PublicApi extends HttpApiGroup.make("public")
|
||||
.add(HttpApiEndpoint.get("greeting", "/greeting").addSuccess(Greeting))
|
||||
.middleware(OptionalAuthMiddleware)
|
||||
{}
|
||||
|
||||
class MyApi extends HttpApi.make("api")
|
||||
.add(UsersApi)
|
||||
.add(PublicApi)
|
||||
{}
|
||||
|
||||
// 4. Implement handlers
|
||||
const UsersLive = HttpApiBuilder.group(MyApi, "users", (handlers) =>
|
||||
handlers
|
||||
.handle("me", () =>
|
||||
Effect.gen(function* () {
|
||||
const user = yield* CurrentUser
|
||||
return new UserProfile({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
})
|
||||
)
|
||||
.handle("session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* CurrentSession
|
||||
return new SessionInfo({
|
||||
id: session.id,
|
||||
expiresAt: session.expiresAt,
|
||||
})
|
||||
})
|
||||
)
|
||||
).pipe(
|
||||
Layer.provide(AuthMiddlewareLive),
|
||||
Layer.provide(SessionMiddlewareLive)
|
||||
)
|
||||
|
||||
const PublicLive = HttpApiBuilder.group(MyApi, "public", (handlers) =>
|
||||
handlers.handle("greeting", () =>
|
||||
Effect.gen(function* () {
|
||||
const maybeUser = yield* OptionalUser
|
||||
const message = maybeUser
|
||||
? `Hello, ${maybeUser.name}!`
|
||||
: "Hello, guest!"
|
||||
return new Greeting({ message })
|
||||
})
|
||||
)
|
||||
).pipe(Layer.provide(OptionalAuthMiddlewareLive))
|
||||
|
||||
// 5. Build and serve
|
||||
const ApiLive = HttpApiBuilder.api(MyApi).pipe(
|
||||
Layer.provide([UsersLive, PublicLive])
|
||||
)
|
||||
|
||||
const AuthRouterLive = HttpApiBuilder.Router.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const authApp = yield* betterAuthApp
|
||||
yield* router.mountApp("/api/auth", authApp)
|
||||
})
|
||||
)
|
||||
|
||||
HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
|
||||
Layer.provide(AuthRouterLive),
|
||||
Layer.provide(ApiLive),
|
||||
Layer.provide(BetterAuthLive),
|
||||
Layer.provide(HttpApiBuilder.middlewareCors({
|
||||
allowedOrigins: ["http://localhost:3001"],
|
||||
credentials: true,
|
||||
})),
|
||||
HttpServer.withLogAddress,
|
||||
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
|
||||
Layer.launch,
|
||||
NodeRuntime.runMain
|
||||
)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Learn more about [Effect](https://effect.website/docs/introduction)
|
||||
- Explore [Effect/Platform HttpApi](https://effect.website/docs/platform/http-api)
|
||||
- Check out [Better Auth plugins](/docs/plugins) for additional features
|
||||
@@ -0,0 +1,375 @@
|
||||
# @better-auth/effect
|
||||
|
||||
Effect-native integration for [Better Auth](https://www.better-auth.com) with [Effect/Platform](https://effect.website).
|
||||
|
||||
## Features
|
||||
|
||||
- 🎯 **Effect-Native** - All auth operations return `Effect`s with typed errors
|
||||
- 🔒 **Type-Safe** - Full type inference from your auth configuration
|
||||
- 🧩 **HttpApi Integration** - Seamless middleware for Effect/Platform's HttpApi
|
||||
- 💉 **Dependency Injection** - Uses Effect's Context/Layer system
|
||||
- ⚡ **Composable** - Works naturally with Effect's ecosystem
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @better-auth/effect better-auth effect @effect/platform
|
||||
# or
|
||||
pnpm add @better-auth/effect better-auth effect @effect/platform
|
||||
# or
|
||||
bun add @better-auth/effect better-auth effect @effect/platform
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure Better Auth
|
||||
|
||||
```typescript
|
||||
// auth.ts
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import { db } from "./db"
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
emailAndPassword: { enabled: true },
|
||||
})
|
||||
|
||||
export type Auth = typeof auth
|
||||
```
|
||||
|
||||
### 2. Create the Effect Layer
|
||||
|
||||
```typescript
|
||||
// auth-effect.ts
|
||||
import { layerFromAuth } from "@better-auth/effect"
|
||||
import { auth } from "./auth"
|
||||
|
||||
export const BetterAuthLive = layerFromAuth(auth)
|
||||
|
||||
// Re-export for convenience
|
||||
export { BetterAuth, CurrentUser, CurrentSession } from "@better-auth/effect"
|
||||
```
|
||||
|
||||
### 3. Use in HttpApi
|
||||
|
||||
```typescript
|
||||
// api.ts
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiBuilder,
|
||||
HttpApiEndpoint,
|
||||
HttpApiGroup,
|
||||
HttpServer,
|
||||
HttpMiddleware,
|
||||
} from "@effect/platform"
|
||||
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import {
|
||||
AuthMiddleware,
|
||||
AuthMiddlewareLive,
|
||||
CurrentUser,
|
||||
betterAuthApp,
|
||||
} from "@better-auth/effect"
|
||||
import { BetterAuthLive } from "./auth-effect"
|
||||
|
||||
// Define a user profile schema
|
||||
class UserProfile extends Schema.Class<UserProfile>("UserProfile")({
|
||||
id: Schema.String,
|
||||
email: Schema.String,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
// Protected API group
|
||||
class UsersApi extends HttpApiGroup.make("users")
|
||||
.add(
|
||||
HttpApiEndpoint.get("me", "/me")
|
||||
.addSuccess(UserProfile)
|
||||
)
|
||||
.middleware(AuthMiddleware) // Requires authentication
|
||||
.prefix("/users")
|
||||
{}
|
||||
|
||||
class MyApi extends HttpApi.make("my-api").add(UsersApi) {}
|
||||
|
||||
// Implement handlers
|
||||
const UsersLive = HttpApiBuilder.group(MyApi, "users", (handlers) =>
|
||||
handlers.handle("me", () =>
|
||||
Effect.gen(function* () {
|
||||
const user = yield* CurrentUser
|
||||
return new UserProfile({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
})
|
||||
)
|
||||
).pipe(Layer.provide(AuthMiddlewareLive))
|
||||
|
||||
// Build and serve
|
||||
const ApiLive = HttpApiBuilder.api(MyApi).pipe(
|
||||
Layer.provide(UsersLive)
|
||||
)
|
||||
|
||||
// Mount better-auth handler
|
||||
const RouterLive = HttpApiBuilder.Router.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const authApp = yield* betterAuthApp
|
||||
yield* router.mountApp("/api/auth", authApp)
|
||||
})
|
||||
)
|
||||
|
||||
HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
|
||||
Layer.provide(RouterLive),
|
||||
Layer.provide(ApiLive),
|
||||
Layer.provide(BetterAuthLive),
|
||||
HttpServer.withLogAddress,
|
||||
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
|
||||
Layer.launch,
|
||||
NodeRuntime.runMain
|
||||
)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Service
|
||||
|
||||
#### `BetterAuth`
|
||||
|
||||
The main service tag for accessing auth operations.
|
||||
|
||||
```typescript
|
||||
import { BetterAuth } from "@better-auth/effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const auth = yield* BetterAuth
|
||||
|
||||
// Get session
|
||||
const session = yield* auth.getSession(headers)
|
||||
|
||||
// Sign in
|
||||
const result = yield* auth.signInEmail({
|
||||
email: "user@example.com",
|
||||
password: "password",
|
||||
})
|
||||
|
||||
// Sign up
|
||||
const newUser = yield* auth.signUpEmail({
|
||||
email: "new@example.com",
|
||||
password: "password",
|
||||
name: "New User",
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### `layer(options)`
|
||||
|
||||
Creates a layer from Better Auth options.
|
||||
|
||||
```typescript
|
||||
import { layer } from "@better-auth/effect"
|
||||
|
||||
const BetterAuthLive = layer({
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
emailAndPassword: { enabled: true },
|
||||
})
|
||||
```
|
||||
|
||||
#### `layerFromAuth(auth)`
|
||||
|
||||
Creates a layer from an existing Better Auth instance.
|
||||
|
||||
```typescript
|
||||
import { layerFromAuth } from "@better-auth/effect"
|
||||
import { auth } from "./auth"
|
||||
|
||||
const BetterAuthLive = layerFromAuth(auth)
|
||||
```
|
||||
|
||||
### Session Context
|
||||
|
||||
#### `CurrentUser`
|
||||
|
||||
Context tag for the authenticated user.
|
||||
|
||||
```typescript
|
||||
import { CurrentUser } from "@better-auth/effect"
|
||||
|
||||
const handler = Effect.gen(function* () {
|
||||
const user = yield* CurrentUser
|
||||
return { id: user.id, email: user.email }
|
||||
})
|
||||
```
|
||||
|
||||
#### `CurrentSession`
|
||||
|
||||
Context tag for the session data.
|
||||
|
||||
```typescript
|
||||
import { CurrentSession } from "@better-auth/effect"
|
||||
|
||||
const handler = Effect.gen(function* () {
|
||||
const session = yield* CurrentSession
|
||||
return { token: session.token, expiresAt: session.expiresAt }
|
||||
})
|
||||
```
|
||||
|
||||
#### `AuthSession`
|
||||
|
||||
Context tag for both user and session.
|
||||
|
||||
```typescript
|
||||
import { AuthSession } from "@better-auth/effect"
|
||||
|
||||
const handler = Effect.gen(function* () {
|
||||
const { user, session } = yield* AuthSession
|
||||
})
|
||||
```
|
||||
|
||||
#### `OptionalUser`
|
||||
|
||||
Context tag for optional authentication.
|
||||
|
||||
```typescript
|
||||
import { OptionalUser } from "@better-auth/effect"
|
||||
|
||||
const handler = Effect.gen(function* () {
|
||||
const maybeUser = yield* OptionalUser
|
||||
if (maybeUser) {
|
||||
return { message: `Hello, ${maybeUser.name}!` }
|
||||
}
|
||||
return { message: "Hello, guest!" }
|
||||
})
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
#### `AuthMiddleware` / `AuthMiddlewareLive`
|
||||
|
||||
Requires authentication, provides `CurrentUser`.
|
||||
|
||||
```typescript
|
||||
class ProtectedApi extends HttpApiGroup.make("protected")
|
||||
.add(HttpApiEndpoint.get("data", "/data").addSuccess(Data))
|
||||
.middleware(AuthMiddleware)
|
||||
{}
|
||||
|
||||
// Provide the implementation
|
||||
Layer.provide(AuthMiddlewareLive)
|
||||
```
|
||||
|
||||
#### `SessionMiddleware` / `SessionMiddlewareLive`
|
||||
|
||||
Requires authentication, provides `CurrentSession`.
|
||||
|
||||
#### `FullAuthMiddleware` / `FullAuthMiddlewareLive`
|
||||
|
||||
Requires authentication, provides `AuthSession` (user + session).
|
||||
|
||||
#### `OptionalAuthMiddleware` / `OptionalAuthMiddlewareLive`
|
||||
|
||||
Optional authentication, provides `OptionalUser`.
|
||||
|
||||
#### `AllAuthMiddlewareLive`
|
||||
|
||||
Combined layer for all middleware types.
|
||||
|
||||
```typescript
|
||||
Layer.provide(AllAuthMiddlewareLive)
|
||||
```
|
||||
|
||||
### Handler Mounting
|
||||
|
||||
#### `betterAuthApp`
|
||||
|
||||
Creates an HttpApp from the BetterAuth service.
|
||||
|
||||
```typescript
|
||||
import { betterAuthApp } from "@better-auth/effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const authApp = yield* betterAuthApp
|
||||
// Mount at /api/auth
|
||||
})
|
||||
```
|
||||
|
||||
#### `betterAuthAppFrom(auth)`
|
||||
|
||||
Creates an HttpApp directly from a Better Auth instance.
|
||||
|
||||
```typescript
|
||||
import { betterAuthAppFrom } from "@better-auth/effect"
|
||||
import { auth } from "./auth"
|
||||
|
||||
const authApp = betterAuthAppFrom(auth)
|
||||
```
|
||||
|
||||
### Errors
|
||||
|
||||
All errors are Effect Schema `TaggedError` classes with HTTP status codes:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Unauthorized, // 401
|
||||
InvalidCredentials, // 401
|
||||
SessionExpired, // 401
|
||||
Forbidden, // 403
|
||||
UserNotFound, // 404
|
||||
EmailAlreadyExists, // 409
|
||||
RateLimited, // 429
|
||||
} from "@better-auth/effect"
|
||||
|
||||
// Handle specific errors
|
||||
program.pipe(
|
||||
Effect.catchTag("Unauthorized", (e) =>
|
||||
Effect.succeed({ error: "Please log in" })
|
||||
),
|
||||
Effect.catchTag("RateLimited", (e) =>
|
||||
Effect.succeed({ error: `Try again in ${e.retryAfter}s` })
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Typed Session Context
|
||||
|
||||
For exact type inference from your auth config:
|
||||
|
||||
```typescript
|
||||
import { makeSessionContext } from "@better-auth/effect"
|
||||
import type { auth } from "./auth"
|
||||
|
||||
const { CurrentUser, CurrentSession, AuthSession } = makeSessionContext<typeof auth>()
|
||||
|
||||
// CurrentUser.Type is now exactly your user type
|
||||
```
|
||||
|
||||
### Custom Cookie Name
|
||||
|
||||
If you've customized the session cookie name:
|
||||
|
||||
```typescript
|
||||
// Create custom middleware with your cookie name
|
||||
import { HttpApiMiddleware, HttpApiSecurity } from "@effect/platform"
|
||||
import { Unauthorized } from "@better-auth/effect"
|
||||
|
||||
class CustomAuthMiddleware extends HttpApiMiddleware.Tag<CustomAuthMiddleware>()(
|
||||
"CustomAuthMiddleware",
|
||||
{
|
||||
failure: Unauthorized,
|
||||
provides: CurrentUser,
|
||||
security: {
|
||||
cookie: HttpApiSecurity.apiKey({
|
||||
in: "cookie",
|
||||
key: "my-custom-session-cookie",
|
||||
}),
|
||||
},
|
||||
}
|
||||
) {}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@better-auth/effect",
|
||||
"version": "0.0.1",
|
||||
"description": "Effect-native integration for Better Auth with Effect/Platform",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"better-auth",
|
||||
"effect",
|
||||
"effect-ts",
|
||||
"effect-platform",
|
||||
"authentication",
|
||||
"auth",
|
||||
"typescript"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/better-auth/better-auth.git",
|
||||
"directory": "packages/effect"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/better-auth/better-auth/issues"
|
||||
},
|
||||
"homepage": "https://www.better-auth.com",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"@effect/platform": ">=0.72.0",
|
||||
"better-auth": "^1.0.0",
|
||||
"effect": ">=3.12.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@effect/platform": {
|
||||
"optional": false
|
||||
},
|
||||
"effect": {
|
||||
"optional": false
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform": "^0.72.0",
|
||||
"@effect/platform-node": "^0.66.0",
|
||||
"better-auth": "workspace:*",
|
||||
"effect": "^3.12.0",
|
||||
"tsdown": "^0.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* @module BetterAuth
|
||||
* @description Effect-native Better Auth service with typed operations.
|
||||
*/
|
||||
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { Auth, BetterAuthOptions } from "better-auth";
|
||||
import { betterAuth } from "better-auth";
|
||||
import * as Errors from "./Errors.js";
|
||||
import type { InferUser, InferSession } from "./Session.js";
|
||||
|
||||
// ============================================================================
|
||||
// Service Interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* The BetterAuth service interface providing Effect-wrapped auth operations.
|
||||
*/
|
||||
export interface BetterAuthService<A extends Auth<any> = Auth<any>> {
|
||||
/**
|
||||
* The underlying better-auth instance
|
||||
*/
|
||||
readonly auth: A;
|
||||
|
||||
/**
|
||||
* Get the current session from request headers.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const session = yield* betterAuth.getSession(request.headers)
|
||||
* ```
|
||||
*/
|
||||
readonly getSession: (
|
||||
headers: Headers
|
||||
) => Effect.Effect<
|
||||
{ user: InferUser<A>; session: InferSession<A> },
|
||||
Errors.Unauthorized
|
||||
>;
|
||||
|
||||
/**
|
||||
* Sign in with email and password.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { user, session } = yield* betterAuth.signInEmail({
|
||||
* email: "user@example.com",
|
||||
* password: "password123",
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
readonly signInEmail: (params: {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}) => Effect.Effect<
|
||||
{ user: InferUser<A>; session: InferSession<A> },
|
||||
Errors.InvalidCredentials | Errors.UserNotFound | Errors.RateLimited
|
||||
>;
|
||||
|
||||
/**
|
||||
* Sign up with email and password.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { user, session } = yield* betterAuth.signUpEmail({
|
||||
* email: "newuser@example.com",
|
||||
* password: "password123",
|
||||
* name: "New User",
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
readonly signUpEmail: (params: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
}) => Effect.Effect<
|
||||
{ user: InferUser<A>; session: InferSession<A> },
|
||||
Errors.EmailAlreadyExists | Errors.RateLimited
|
||||
>;
|
||||
|
||||
/**
|
||||
* Sign out the current session.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* yield* betterAuth.signOut(request.headers)
|
||||
* ```
|
||||
*/
|
||||
readonly signOut: (headers: Headers) => Effect.Effect<void, Errors.Unauthorized>;
|
||||
|
||||
/**
|
||||
* List all active sessions for the current user.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sessions = yield* betterAuth.listSessions(request.headers)
|
||||
* ```
|
||||
*/
|
||||
readonly listSessions: (
|
||||
headers: Headers
|
||||
) => Effect.Effect<Array<InferSession<A>>, Errors.Unauthorized>;
|
||||
|
||||
/**
|
||||
* Revoke a specific session by token.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* yield* betterAuth.revokeSession(sessionToken, request.headers)
|
||||
* ```
|
||||
*/
|
||||
readonly revokeSession: (
|
||||
token: string,
|
||||
headers: Headers
|
||||
) => Effect.Effect<void, Errors.Unauthorized>;
|
||||
|
||||
/**
|
||||
* Revoke all sessions except the current one.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* yield* betterAuth.revokeOtherSessions(request.headers)
|
||||
* ```
|
||||
*/
|
||||
readonly revokeOtherSessions: (
|
||||
headers: Headers
|
||||
) => Effect.Effect<void, Errors.Unauthorized>;
|
||||
|
||||
/**
|
||||
* The raw request handler for mounting.
|
||||
* Use this with HttpRouter.mountApp or similar.
|
||||
*/
|
||||
readonly handler: (request: Request) => Promise<Response>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service Tag
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Context tag for the BetterAuth service.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { BetterAuth } from "@better-auth/effect"
|
||||
*
|
||||
* const program = Effect.gen(function* () {
|
||||
* const auth = yield* BetterAuth
|
||||
* const session = yield* auth.getSession(headers)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class BetterAuth extends Context.Tag("@better-auth/effect/BetterAuth")<
|
||||
BetterAuth,
|
||||
BetterAuthService
|
||||
>() {}
|
||||
|
||||
// ============================================================================
|
||||
// Error Mapping
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maps API errors from better-auth to typed Effect errors
|
||||
*/
|
||||
const mapApiError = (error: unknown): Errors.AnyAuthError => {
|
||||
const err = error as any;
|
||||
const code = err?.code || err?.body?.code || err?.status || "UNKNOWN";
|
||||
const message =
|
||||
err?.message || err?.body?.message || err?.statusText || "An error occurred";
|
||||
|
||||
switch (code) {
|
||||
case "UNAUTHORIZED":
|
||||
case "INVALID_SESSION":
|
||||
return new Errors.Unauthorized({ message });
|
||||
|
||||
case "INVALID_CREDENTIALS":
|
||||
case "INVALID_PASSWORD":
|
||||
case "INVALID_EMAIL_OR_PASSWORD":
|
||||
return new Errors.InvalidCredentials({ message });
|
||||
|
||||
case "USER_NOT_FOUND":
|
||||
return new Errors.UserNotFound({ message });
|
||||
|
||||
case "EMAIL_ALREADY_EXISTS":
|
||||
case "USER_ALREADY_EXISTS":
|
||||
return new Errors.EmailAlreadyExists({ message });
|
||||
|
||||
case "RATE_LIMIT_EXCEEDED":
|
||||
case "TOO_MANY_REQUESTS":
|
||||
return new Errors.RateLimited({
|
||||
message,
|
||||
retryAfter: err?.retryAfter,
|
||||
});
|
||||
|
||||
case "SESSION_EXPIRED":
|
||||
return new Errors.SessionExpired({ message });
|
||||
|
||||
case "FORBIDDEN":
|
||||
return new Errors.Forbidden({ message, code });
|
||||
|
||||
default:
|
||||
return new Errors.AuthError({ message, code: String(code) });
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Service Implementation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a BetterAuth service instance.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const service = yield* make({
|
||||
* database: drizzleAdapter(db, { provider: "pg" }),
|
||||
* emailAndPassword: { enabled: true },
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const make = <Options extends BetterAuthOptions>(
|
||||
options: Options
|
||||
): Effect.Effect<BetterAuthService<Auth<Options>>> =>
|
||||
Effect.sync(() => {
|
||||
const auth = betterAuth(options);
|
||||
|
||||
const service: BetterAuthService<Auth<Options>> = {
|
||||
auth: auth as Auth<Options>,
|
||||
|
||||
getSession: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.getSession({ headers }),
|
||||
catch: mapApiError,
|
||||
}).pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session
|
||||
? Effect.succeed(session as any)
|
||||
: Effect.fail(
|
||||
new Errors.Unauthorized({ message: "No session found" })
|
||||
)
|
||||
)
|
||||
) as any,
|
||||
|
||||
signInEmail: (params) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth.api.signInEmail({
|
||||
body: params,
|
||||
}),
|
||||
catch: mapApiError,
|
||||
}) as any,
|
||||
|
||||
signUpEmail: (params) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth.api.signUpEmail({
|
||||
body: params,
|
||||
}),
|
||||
catch: mapApiError,
|
||||
}) as any,
|
||||
|
||||
signOut: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.signOut({ headers }),
|
||||
catch: mapApiError,
|
||||
}).pipe(Effect.asVoid) as any,
|
||||
|
||||
listSessions: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.listSessions({ headers }),
|
||||
catch: mapApiError,
|
||||
}) as any,
|
||||
|
||||
revokeSession: (token: string, headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth.api.revokeSession({
|
||||
headers,
|
||||
body: { token },
|
||||
}),
|
||||
catch: mapApiError,
|
||||
}).pipe(Effect.asVoid) as any,
|
||||
|
||||
revokeOtherSessions: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.revokeOtherSessions({ headers }),
|
||||
catch: mapApiError,
|
||||
}).pipe(Effect.asVoid) as any,
|
||||
|
||||
handler: auth.handler,
|
||||
};
|
||||
|
||||
return service;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Layer Constructor
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a Layer that provides the BetterAuth service.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { layer } from "@better-auth/effect"
|
||||
*
|
||||
* const BetterAuthLive = layer({
|
||||
* database: drizzleAdapter(db, { provider: "pg" }),
|
||||
* emailAndPassword: { enabled: true },
|
||||
* })
|
||||
*
|
||||
* // Use in your app
|
||||
* program.pipe(Effect.provide(BetterAuthLive))
|
||||
* ```
|
||||
*/
|
||||
export const layer = <Options extends BetterAuthOptions>(
|
||||
options: Options
|
||||
): Layer.Layer<BetterAuth> => Layer.effect(BetterAuth, make(options)) as any;
|
||||
|
||||
/**
|
||||
* Creates a Layer from an existing better-auth instance.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { auth } from "./auth"
|
||||
* import { layerFromAuth } from "@better-auth/effect"
|
||||
*
|
||||
* const BetterAuthLive = layerFromAuth(auth)
|
||||
* ```
|
||||
*/
|
||||
export const layerFromAuth = <A extends Auth<any>>(
|
||||
auth: A
|
||||
): Layer.Layer<BetterAuth> =>
|
||||
Layer.succeed(BetterAuth, {
|
||||
auth,
|
||||
|
||||
getSession: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.getSession({ headers }),
|
||||
catch: mapApiError,
|
||||
}).pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session
|
||||
? Effect.succeed(session as any)
|
||||
: Effect.fail(
|
||||
new Errors.Unauthorized({ message: "No session found" })
|
||||
)
|
||||
)
|
||||
) as any,
|
||||
|
||||
signInEmail: (params) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.signInEmail({ body: params }),
|
||||
catch: mapApiError,
|
||||
}) as any,
|
||||
|
||||
signUpEmail: (params) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.signUpEmail({ body: params }),
|
||||
catch: mapApiError,
|
||||
}) as any,
|
||||
|
||||
signOut: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.signOut({ headers }),
|
||||
catch: mapApiError,
|
||||
}).pipe(Effect.asVoid) as any,
|
||||
|
||||
listSessions: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.listSessions({ headers }),
|
||||
catch: mapApiError,
|
||||
}) as any,
|
||||
|
||||
revokeSession: (token: string, headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.revokeSession({ headers, body: { token } }),
|
||||
catch: mapApiError,
|
||||
}).pipe(Effect.asVoid) as any,
|
||||
|
||||
revokeOtherSessions: (headers: Headers) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth.api.revokeOtherSessions({ headers }),
|
||||
catch: mapApiError,
|
||||
}).pipe(Effect.asVoid) as any,
|
||||
|
||||
handler: auth.handler,
|
||||
} as BetterAuthService) as any;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @module Errors
|
||||
* @description Typed error classes for Better Auth Effect integration.
|
||||
* All errors extend Effect Schema's TaggedError for type-safe error handling.
|
||||
*/
|
||||
|
||||
import { Schema } from "effect";
|
||||
import { HttpApiSchema } from "@effect/platform";
|
||||
|
||||
/**
|
||||
* Base authentication error
|
||||
*/
|
||||
export class AuthError extends Schema.TaggedError<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
code: Schema.String,
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Unauthorized error - returned when no valid session exists
|
||||
* HTTP Status: 401
|
||||
*/
|
||||
export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
|
||||
"Unauthorized",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 401 })
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Forbidden error - returned when user lacks permission
|
||||
* HTTP Status: 403
|
||||
*/
|
||||
export class Forbidden extends Schema.TaggedError<Forbidden>()(
|
||||
"Forbidden",
|
||||
{
|
||||
message: Schema.String,
|
||||
code: Schema.optional(Schema.String),
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 403 })
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Session expired error - returned when session has expired
|
||||
* HTTP Status: 401
|
||||
*/
|
||||
export class SessionExpired extends Schema.TaggedError<SessionExpired>()(
|
||||
"SessionExpired",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 401 })
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Invalid credentials error - returned on failed login attempt
|
||||
* HTTP Status: 401
|
||||
*/
|
||||
export class InvalidCredentials extends Schema.TaggedError<InvalidCredentials>()(
|
||||
"InvalidCredentials",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 401 })
|
||||
) {}
|
||||
|
||||
/**
|
||||
* User not found error
|
||||
* HTTP Status: 404
|
||||
*/
|
||||
export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
||||
"UserNotFound",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 404 })
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Email already exists error - returned on signup with existing email
|
||||
* HTTP Status: 409
|
||||
*/
|
||||
export class EmailAlreadyExists extends Schema.TaggedError<EmailAlreadyExists>()(
|
||||
"EmailAlreadyExists",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 409 })
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Rate limited error - returned when rate limit is exceeded
|
||||
* HTTP Status: 429
|
||||
*/
|
||||
export class RateLimited extends Schema.TaggedError<RateLimited>()(
|
||||
"RateLimited",
|
||||
{
|
||||
message: Schema.String,
|
||||
retryAfter: Schema.optional(Schema.Number),
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 429 })
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Internal auth error - unexpected errors
|
||||
* HTTP Status: 500
|
||||
*/
|
||||
export class InternalAuthError extends Schema.TaggedError<InternalAuthError>()(
|
||||
"InternalAuthError",
|
||||
{
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
},
|
||||
HttpApiSchema.annotations({ status: 500 })
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Union type of all auth errors for type narrowing
|
||||
*/
|
||||
export type AnyAuthError =
|
||||
| AuthError
|
||||
| Unauthorized
|
||||
| Forbidden
|
||||
| SessionExpired
|
||||
| InvalidCredentials
|
||||
| UserNotFound
|
||||
| EmailAlreadyExists
|
||||
| RateLimited
|
||||
| InternalAuthError;
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* @module HttpApi
|
||||
* @description HttpApi integration for Better Auth with Effect/Platform.
|
||||
* Provides middleware, security, and mounting utilities.
|
||||
*/
|
||||
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import {
|
||||
HttpApiMiddleware,
|
||||
HttpApiSecurity,
|
||||
HttpServerRequest,
|
||||
HttpApp,
|
||||
HttpRouter,
|
||||
HttpServerResponse,
|
||||
} from "@effect/platform";
|
||||
import type { HttpServerError } from "@effect/platform";
|
||||
import { BetterAuth, type BetterAuthService } from "./BetterAuth.js";
|
||||
import { CurrentUser, CurrentSession, AuthSession, OptionalUser } from "./Session.js";
|
||||
import * as Errors from "./Errors.js";
|
||||
|
||||
// ============================================================================
|
||||
// Cookie Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Default session cookie name used by better-auth
|
||||
*/
|
||||
export const DEFAULT_SESSION_COOKIE = "better-auth.session_token";
|
||||
|
||||
/**
|
||||
* Configuration for the auth middleware
|
||||
*/
|
||||
export interface AuthMiddlewareConfig {
|
||||
/**
|
||||
* The name of the session cookie
|
||||
* @default "better-auth.session_token"
|
||||
*/
|
||||
readonly cookieName?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authentication Middleware
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Authentication middleware that provides the CurrentUser context.
|
||||
* Use this on HttpApiGroup or HttpApiEndpoint to require authentication.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { AuthMiddleware } from "@better-auth/effect"
|
||||
*
|
||||
* class ProtectedApi extends HttpApiGroup.make("protected")
|
||||
* .add(HttpApiEndpoint.get("me", "/me").addSuccess(UserProfile))
|
||||
* .middleware(AuthMiddleware)
|
||||
* {}
|
||||
* ```
|
||||
*/
|
||||
export class AuthMiddleware extends HttpApiMiddleware.Tag<AuthMiddleware>()(
|
||||
"@better-auth/effect/AuthMiddleware",
|
||||
{
|
||||
failure: Errors.Unauthorized,
|
||||
provides: CurrentUser,
|
||||
security: {
|
||||
cookie: HttpApiSecurity.apiKey({
|
||||
in: "cookie",
|
||||
key: DEFAULT_SESSION_COOKIE,
|
||||
}),
|
||||
},
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Layer that provides the AuthMiddleware implementation.
|
||||
* Requires BetterAuth service to be available.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const UsersLive = HttpApiBuilder.group(MyApi, "users", (handlers) =>
|
||||
* handlers.handle("me", () => CurrentUser)
|
||||
* ).pipe(Layer.provide(AuthMiddlewareLive))
|
||||
* ```
|
||||
*/
|
||||
export const AuthMiddlewareLive: Layer.Layer<AuthMiddleware, never, BetterAuth> =
|
||||
Layer.effect(
|
||||
AuthMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const betterAuth = yield* BetterAuth;
|
||||
|
||||
return {
|
||||
cookie: (_token) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest;
|
||||
const session = yield* betterAuth.getSession(request.headers);
|
||||
return session.user;
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Session Middleware
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Session middleware that provides the CurrentSession context.
|
||||
* Use when you need access to session data (token, expiry, etc.).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { SessionMiddleware } from "@better-auth/effect"
|
||||
*
|
||||
* class SessionApi extends HttpApiGroup.make("session")
|
||||
* .add(HttpApiEndpoint.get("info", "/info").addSuccess(SessionInfo))
|
||||
* .middleware(SessionMiddleware)
|
||||
* {}
|
||||
* ```
|
||||
*/
|
||||
export class SessionMiddleware extends HttpApiMiddleware.Tag<SessionMiddleware>()(
|
||||
"@better-auth/effect/SessionMiddleware",
|
||||
{
|
||||
failure: Errors.Unauthorized,
|
||||
provides: CurrentSession,
|
||||
security: {
|
||||
cookie: HttpApiSecurity.apiKey({
|
||||
in: "cookie",
|
||||
key: DEFAULT_SESSION_COOKIE,
|
||||
}),
|
||||
},
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Layer that provides the SessionMiddleware implementation.
|
||||
*/
|
||||
export const SessionMiddlewareLive: Layer.Layer<
|
||||
SessionMiddleware,
|
||||
never,
|
||||
BetterAuth
|
||||
> = Layer.effect(
|
||||
SessionMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const betterAuth = yield* BetterAuth;
|
||||
|
||||
return {
|
||||
cookie: (_token) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest;
|
||||
const session = yield* betterAuth.getSession(request.headers);
|
||||
return session.session;
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Full Auth Session Middleware
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Full auth session middleware that provides both user and session.
|
||||
* Use when you need access to both user and session data.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { FullAuthMiddleware, AuthSession } from "@better-auth/effect"
|
||||
*
|
||||
* const handler = Effect.gen(function* () {
|
||||
* const { user, session } = yield* AuthSession
|
||||
* // Access both user and session
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class FullAuthMiddleware extends HttpApiMiddleware.Tag<FullAuthMiddleware>()(
|
||||
"@better-auth/effect/FullAuthMiddleware",
|
||||
{
|
||||
failure: Errors.Unauthorized,
|
||||
provides: AuthSession,
|
||||
security: {
|
||||
cookie: HttpApiSecurity.apiKey({
|
||||
in: "cookie",
|
||||
key: DEFAULT_SESSION_COOKIE,
|
||||
}),
|
||||
},
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Layer that provides the FullAuthMiddleware implementation.
|
||||
*/
|
||||
export const FullAuthMiddlewareLive: Layer.Layer<
|
||||
FullAuthMiddleware,
|
||||
never,
|
||||
BetterAuth
|
||||
> = Layer.effect(
|
||||
FullAuthMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const betterAuth = yield* BetterAuth;
|
||||
|
||||
return {
|
||||
cookie: (_token) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest;
|
||||
const session = yield* betterAuth.getSession(request.headers);
|
||||
return { user: session.user, session: session.session };
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Optional Auth Middleware
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Optional authentication middleware.
|
||||
* Provides user if authenticated, undefined otherwise. Never fails.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { OptionalAuthMiddleware, OptionalUser } from "@better-auth/effect"
|
||||
*
|
||||
* class PublicApi extends HttpApiGroup.make("public")
|
||||
* .add(HttpApiEndpoint.get("greeting", "/greeting").addSuccess(Greeting))
|
||||
* .middleware(OptionalAuthMiddleware)
|
||||
* {}
|
||||
*
|
||||
* const handler = Effect.gen(function* () {
|
||||
* const maybeUser = yield* OptionalUser
|
||||
* if (maybeUser) {
|
||||
* return { message: `Hello, ${maybeUser.name}!` }
|
||||
* }
|
||||
* return { message: "Hello, guest!" }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class OptionalAuthMiddleware extends HttpApiMiddleware.Tag<OptionalAuthMiddleware>()(
|
||||
"@better-auth/effect/OptionalAuthMiddleware",
|
||||
{
|
||||
provides: OptionalUser,
|
||||
security: {
|
||||
cookie: HttpApiSecurity.apiKey({
|
||||
in: "cookie",
|
||||
key: DEFAULT_SESSION_COOKIE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ optional: true }
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Layer that provides the OptionalAuthMiddleware implementation.
|
||||
*/
|
||||
export const OptionalAuthMiddlewareLive: Layer.Layer<
|
||||
OptionalAuthMiddleware,
|
||||
never,
|
||||
BetterAuth
|
||||
> = Layer.effect(
|
||||
OptionalAuthMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const betterAuth = yield* BetterAuth;
|
||||
|
||||
return {
|
||||
cookie: (_token) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest;
|
||||
const result = yield* Effect.either(
|
||||
betterAuth.getSession(request.headers)
|
||||
);
|
||||
return result._tag === "Right" ? result.right.user : undefined;
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Handler Mounting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates an HttpApp from the BetterAuth handler.
|
||||
* Use this to mount better-auth routes in your HttpRouter.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { betterAuthApp } from "@better-auth/effect"
|
||||
*
|
||||
* const program = Effect.gen(function* () {
|
||||
* const authApp = yield* betterAuthApp
|
||||
* // Mount at /api/auth
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const betterAuthApp: Effect.Effect<
|
||||
HttpApp.Default<HttpServerError.HttpServerError>,
|
||||
never,
|
||||
BetterAuth
|
||||
> = Effect.gen(function* () {
|
||||
const { handler } = yield* BetterAuth;
|
||||
return HttpApp.fromWebHandler(handler);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates an HttpApp from a better-auth instance directly.
|
||||
* Useful when you don't want to use the service pattern.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { auth } from "./auth"
|
||||
* import { betterAuthAppFrom } from "@better-auth/effect"
|
||||
*
|
||||
* const authApp = betterAuthAppFrom(auth)
|
||||
* ```
|
||||
*/
|
||||
export const betterAuthAppFrom = (auth: {
|
||||
handler: (request: Request) => Promise<Response>;
|
||||
}): HttpApp.Default<HttpServerError.HttpServerError> =>
|
||||
HttpApp.fromWebHandler(auth.handler);
|
||||
|
||||
/**
|
||||
* Effect that mounts better-auth at a specified path using the Router service.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { mountBetterAuth } from "@better-auth/effect"
|
||||
*
|
||||
* // In your server setup
|
||||
* HttpApiBuilder.Router.use((router) =>
|
||||
* Effect.gen(function* () {
|
||||
* yield* mountBetterAuth("/api/auth")
|
||||
* })
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const mountBetterAuth = (
|
||||
path: `/${string}` = "/api/auth"
|
||||
): Effect.Effect<
|
||||
void,
|
||||
never,
|
||||
BetterAuth | HttpRouter.HttpRouter.Service<never, never>
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
const authApp = yield* betterAuthApp;
|
||||
const router = yield* HttpRouter.HttpRouter as any;
|
||||
yield* router.mountApp(path, authApp);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Combined Middleware Layers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Combined layer that provides all auth middleware implementations.
|
||||
* Convenient when you use multiple middleware types.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { AllAuthMiddlewareLive } from "@better-auth/effect"
|
||||
*
|
||||
* const ApiLive = HttpApiBuilder.api(MyApi).pipe(
|
||||
* Layer.provide(AllAuthMiddlewareLive),
|
||||
* Layer.provide(BetterAuthLive)
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const AllAuthMiddlewareLive: Layer.Layer<
|
||||
AuthMiddleware | SessionMiddleware | FullAuthMiddleware | OptionalAuthMiddleware,
|
||||
never,
|
||||
BetterAuth
|
||||
> = Layer.mergeAll(
|
||||
AuthMiddlewareLive,
|
||||
SessionMiddlewareLive,
|
||||
FullAuthMiddlewareLive,
|
||||
OptionalAuthMiddlewareLive
|
||||
);
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @module Session
|
||||
* @description Session context tags for accessing authenticated user and session data.
|
||||
*/
|
||||
|
||||
import { Context } from "effect";
|
||||
import type { Auth } from "better-auth";
|
||||
|
||||
// ============================================================================
|
||||
// Type Inference Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Infer the User type from a Better Auth instance
|
||||
*/
|
||||
export type InferUser<A extends Auth<any>> = A["$Infer"]["Session"]["user"];
|
||||
|
||||
/**
|
||||
* Infer the Session type from a Better Auth instance
|
||||
*/
|
||||
export type InferSession<A extends Auth<any>> =
|
||||
A["$Infer"]["Session"]["session"];
|
||||
|
||||
/**
|
||||
* Infer the full session data (user + session) from a Better Auth instance
|
||||
*/
|
||||
export type InferSessionData<A extends Auth<any>> = {
|
||||
user: InferUser<A>;
|
||||
session: InferSession<A>;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Default Context Tags (for generic usage)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Context tag for the current authenticated user.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { CurrentUser } from "@better-auth/effect"
|
||||
*
|
||||
* const handler = Effect.gen(function* () {
|
||||
* const user = yield* CurrentUser
|
||||
* return { id: user.id, email: user.email }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class CurrentUser extends Context.Tag("@better-auth/effect/CurrentUser")<
|
||||
CurrentUser,
|
||||
{
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly image?: string | null;
|
||||
readonly emailVerified: boolean;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
>() {}
|
||||
|
||||
/**
|
||||
* Context tag for the current session.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { CurrentSession } from "@better-auth/effect"
|
||||
*
|
||||
* const handler = Effect.gen(function* () {
|
||||
* const session = yield* CurrentSession
|
||||
* return { token: session.token, expiresAt: session.expiresAt }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class CurrentSession extends Context.Tag(
|
||||
"@better-auth/effect/CurrentSession"
|
||||
)<
|
||||
CurrentSession,
|
||||
{
|
||||
readonly id: string;
|
||||
readonly token: string;
|
||||
readonly userId: string;
|
||||
readonly expiresAt: Date;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
readonly ipAddress?: string | null;
|
||||
readonly userAgent?: string | null;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
>() {}
|
||||
|
||||
/**
|
||||
* Context tag for combined session data (user + session).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { AuthSession } from "@better-auth/effect"
|
||||
*
|
||||
* const handler = Effect.gen(function* () {
|
||||
* const { user, session } = yield* AuthSession
|
||||
* return { userId: user.id, sessionId: session.id }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class AuthSession extends Context.Tag("@better-auth/effect/AuthSession")<
|
||||
AuthSession,
|
||||
{
|
||||
readonly user: CurrentUser["Type"];
|
||||
readonly session: CurrentSession["Type"];
|
||||
}
|
||||
>() {}
|
||||
|
||||
// ============================================================================
|
||||
// Typed Context Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates typed session context tags for a specific Better Auth configuration.
|
||||
* Use this when you need exact type inference from your auth config.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { makeSessionContext } from "@better-auth/effect"
|
||||
* import type { auth } from "./auth"
|
||||
*
|
||||
* const { CurrentUser, CurrentSession, AuthSession } = makeSessionContext<typeof auth>()
|
||||
*
|
||||
* // Now CurrentUser.Type is exactly your user type
|
||||
* ```
|
||||
*/
|
||||
export const makeSessionContext = <A extends Auth<any>>() => {
|
||||
class TypedCurrentUser extends Context.Tag(
|
||||
"@better-auth/effect/CurrentUser"
|
||||
)<TypedCurrentUser, InferUser<A>>() {}
|
||||
|
||||
class TypedCurrentSession extends Context.Tag(
|
||||
"@better-auth/effect/CurrentSession"
|
||||
)<TypedCurrentSession, InferSession<A>>() {}
|
||||
|
||||
class TypedAuthSession extends Context.Tag(
|
||||
"@better-auth/effect/AuthSession"
|
||||
)<
|
||||
TypedAuthSession,
|
||||
{
|
||||
readonly user: InferUser<A>;
|
||||
readonly session: InferSession<A>;
|
||||
}
|
||||
>() {}
|
||||
|
||||
return {
|
||||
CurrentUser: TypedCurrentUser,
|
||||
CurrentSession: TypedCurrentSession,
|
||||
AuthSession: TypedAuthSession,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Optional User Context (for public routes that may have auth)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Context tag for optional user - use on routes where auth is optional.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { OptionalUser } from "@better-auth/effect"
|
||||
*
|
||||
* const handler = Effect.gen(function* () {
|
||||
* const maybeUser = yield* OptionalUser
|
||||
* if (maybeUser) {
|
||||
* return { greeting: `Hello, ${maybeUser.name}!` }
|
||||
* }
|
||||
* return { greeting: "Hello, guest!" }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class OptionalUser extends Context.Tag(
|
||||
"@better-auth/effect/OptionalUser"
|
||||
)<OptionalUser, CurrentUser["Type"] | undefined>() {}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @better-auth/effect
|
||||
*
|
||||
* Effect-native integration for Better Auth with Effect/Platform.
|
||||
*
|
||||
* @example Basic Usage
|
||||
* ```typescript
|
||||
* import { BetterAuth, layer, CurrentUser } from "@better-auth/effect"
|
||||
* import { Effect } from "effect"
|
||||
*
|
||||
* // Create the service layer
|
||||
* const BetterAuthLive = layer({
|
||||
* database: drizzleAdapter(db, { provider: "pg" }),
|
||||
* emailAndPassword: { enabled: true },
|
||||
* })
|
||||
*
|
||||
* // Use in your Effect program
|
||||
* const program = Effect.gen(function* () {
|
||||
* const auth = yield* BetterAuth
|
||||
* const { user, session } = yield* auth.signInEmail({
|
||||
* email: "user@example.com",
|
||||
* password: "password",
|
||||
* })
|
||||
* return user
|
||||
* })
|
||||
*
|
||||
* // Run with the layer
|
||||
* program.pipe(Effect.provide(BetterAuthLive))
|
||||
* ```
|
||||
*
|
||||
* @example HttpApi Integration
|
||||
* ```typescript
|
||||
* import { HttpApiGroup, HttpApiEndpoint, HttpApiBuilder } from "@effect/platform"
|
||||
* import { AuthMiddleware, AuthMiddlewareLive, CurrentUser } from "@better-auth/effect"
|
||||
*
|
||||
* // Define protected API
|
||||
* class UsersApi extends HttpApiGroup.make("users")
|
||||
* .add(HttpApiEndpoint.get("me", "/me").addSuccess(UserProfile))
|
||||
* .middleware(AuthMiddleware)
|
||||
* {}
|
||||
*
|
||||
* // Implement handlers
|
||||
* const UsersLive = HttpApiBuilder.group(MyApi, "users", (handlers) =>
|
||||
* handlers.handle("me", () =>
|
||||
* Effect.gen(function* () {
|
||||
* const user = yield* CurrentUser
|
||||
* return new UserProfile(user)
|
||||
* })
|
||||
* )
|
||||
* ).pipe(Layer.provide(AuthMiddlewareLive))
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
BetterAuth,
|
||||
type BetterAuthService,
|
||||
make,
|
||||
layer,
|
||||
layerFromAuth,
|
||||
} from "./BetterAuth.js";
|
||||
|
||||
// ============================================================================
|
||||
// Session Context
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
CurrentUser,
|
||||
CurrentSession,
|
||||
AuthSession,
|
||||
OptionalUser,
|
||||
makeSessionContext,
|
||||
type InferUser,
|
||||
type InferSession,
|
||||
type InferSessionData,
|
||||
} from "./Session.js";
|
||||
|
||||
// ============================================================================
|
||||
// Errors
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
AuthError,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
SessionExpired,
|
||||
InvalidCredentials,
|
||||
UserNotFound,
|
||||
EmailAlreadyExists,
|
||||
RateLimited,
|
||||
InternalAuthError,
|
||||
type AnyAuthError,
|
||||
} from "./Errors.js";
|
||||
|
||||
// ============================================================================
|
||||
// HttpApi Integration
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
// Middleware
|
||||
AuthMiddleware,
|
||||
AuthMiddlewareLive,
|
||||
SessionMiddleware,
|
||||
SessionMiddlewareLive,
|
||||
FullAuthMiddleware,
|
||||
FullAuthMiddlewareLive,
|
||||
OptionalAuthMiddleware,
|
||||
OptionalAuthMiddlewareLive,
|
||||
AllAuthMiddlewareLive,
|
||||
// Handler mounting
|
||||
betterAuthApp,
|
||||
betterAuthAppFrom,
|
||||
mountBetterAuth,
|
||||
// Configuration
|
||||
DEFAULT_SESSION_COOKIE,
|
||||
type AuthMiddlewareConfig,
|
||||
} from "./HttpApi.js";
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Effect, Layer, Exit } from "effect";
|
||||
import {
|
||||
BetterAuth,
|
||||
layer,
|
||||
layerFromAuth,
|
||||
CurrentUser,
|
||||
CurrentSession,
|
||||
AuthSession,
|
||||
OptionalUser,
|
||||
Unauthorized,
|
||||
InvalidCredentials,
|
||||
EmailAlreadyExists,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("@better-auth/effect", () => {
|
||||
describe("Errors", () => {
|
||||
it("should create Unauthorized error with correct structure", () => {
|
||||
const error = new Unauthorized({ message: "Not authenticated" });
|
||||
expect(error._tag).toBe("Unauthorized");
|
||||
expect(error.message).toBe("Not authenticated");
|
||||
});
|
||||
|
||||
it("should create InvalidCredentials error with correct structure", () => {
|
||||
const error = new InvalidCredentials({ message: "Wrong password" });
|
||||
expect(error._tag).toBe("InvalidCredentials");
|
||||
expect(error.message).toBe("Wrong password");
|
||||
});
|
||||
|
||||
it("should create EmailAlreadyExists error with correct structure", () => {
|
||||
const error = new EmailAlreadyExists({ message: "Email taken" });
|
||||
expect(error._tag).toBe("EmailAlreadyExists");
|
||||
expect(error.message).toBe("Email taken");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Context Tags", () => {
|
||||
it("CurrentUser should have correct service identifier", () => {
|
||||
expect(CurrentUser.key).toBe("@better-auth/effect/CurrentUser");
|
||||
});
|
||||
|
||||
it("CurrentSession should have correct service identifier", () => {
|
||||
expect(CurrentSession.key).toBe("@better-auth/effect/CurrentSession");
|
||||
});
|
||||
|
||||
it("AuthSession should have correct service identifier", () => {
|
||||
expect(AuthSession.key).toBe("@better-auth/effect/AuthSession");
|
||||
});
|
||||
|
||||
it("OptionalUser should have correct service identifier", () => {
|
||||
expect(OptionalUser.key).toBe("@better-auth/effect/OptionalUser");
|
||||
});
|
||||
|
||||
it("BetterAuth should have correct service identifier", () => {
|
||||
expect(BetterAuth.key).toBe("@better-auth/effect/BetterAuth");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layer", () => {
|
||||
it("should export layer function", () => {
|
||||
expect(typeof layer).toBe("function");
|
||||
});
|
||||
|
||||
it("should export layerFromAuth function", () => {
|
||||
expect(typeof layerFromAuth).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Effect Integration", () => {
|
||||
it("should fail with Unauthorized when accessing CurrentUser without provider", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
return yield* CurrentUser;
|
||||
});
|
||||
|
||||
const exit = await Effect.runPromiseExit(program);
|
||||
expect(Exit.isFailure(exit)).toBe(true);
|
||||
});
|
||||
|
||||
it("should work with provided CurrentUser context", async () => {
|
||||
const mockUser = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
return yield* CurrentUser;
|
||||
});
|
||||
|
||||
const result = await program.pipe(
|
||||
Effect.provideService(CurrentUser, mockUser),
|
||||
Effect.runPromise
|
||||
);
|
||||
|
||||
expect(result.id).toBe("user-123");
|
||||
expect(result.email).toBe("test@example.com");
|
||||
});
|
||||
|
||||
it("should work with OptionalUser returning undefined", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
return yield* OptionalUser;
|
||||
});
|
||||
|
||||
const result = await program.pipe(
|
||||
Effect.provideService(OptionalUser, undefined),
|
||||
Effect.runPromise
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should work with OptionalUser returning user", async () => {
|
||||
const mockUser = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
return yield* OptionalUser;
|
||||
});
|
||||
|
||||
const result = await program.pipe(
|
||||
Effect.provideService(OptionalUser, mockUser),
|
||||
Effect.runPromise
|
||||
);
|
||||
|
||||
expect(result?.id).toBe("user-123");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declarationDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
treeshake: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
external: ["effect", "@effect/platform", "better-auth"],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user