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:
dallenpyrah
2026-01-13 14:46:14 -07:00
parent 983c8d20d1
commit f735441ad2
13 changed files with 3229 additions and 0 deletions
+913
View File
@@ -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
+375
View File
@@ -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
+74
View File
@@ -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"
}
}
+388
View File
@@ -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;
+129
View File
@@ -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;
+375
View File
@@ -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
);
+180
View File
@@ -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>() {}
+122
View File
@@ -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";
+137
View File
@@ -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");
});
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declarationDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}
+13
View File
@@ -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"],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
},
});