From f735441ad2425bfd8b823aa582718ae5931e8ce2 Mon Sep 17 00:00:00 2001 From: dallenpyrah Date: Tue, 13 Jan 2026 14:46:14 -0700 Subject: [PATCH] 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 --- EFFECT_PLATFORM_INTEGRATION_PLAN.md | 913 ++++++++++++++++++ .../docs/integrations/effect-platform.mdx | 505 ++++++++++ packages/effect/README.md | 375 +++++++ packages/effect/package.json | 74 ++ packages/effect/src/BetterAuth.ts | 388 ++++++++ packages/effect/src/Errors.ts | 129 +++ packages/effect/src/HttpApi.ts | 375 +++++++ packages/effect/src/Session.ts | 180 ++++ packages/effect/src/index.ts | 122 +++ packages/effect/test/BetterAuth.test.ts | 137 +++ packages/effect/tsconfig.json | 10 + packages/effect/tsdown.config.ts | 13 + packages/effect/vitest.config.ts | 8 + 13 files changed, 3229 insertions(+) create mode 100644 EFFECT_PLATFORM_INTEGRATION_PLAN.md create mode 100644 docs/content/docs/integrations/effect-platform.mdx create mode 100644 packages/effect/README.md create mode 100644 packages/effect/package.json create mode 100644 packages/effect/src/BetterAuth.ts create mode 100644 packages/effect/src/Errors.ts create mode 100644 packages/effect/src/HttpApi.ts create mode 100644 packages/effect/src/Session.ts create mode 100644 packages/effect/src/index.ts create mode 100644 packages/effect/test/BetterAuth.test.ts create mode 100644 packages/effect/tsconfig.json create mode 100644 packages/effect/tsdown.config.ts create mode 100644 packages/effect/vitest.config.ts diff --git a/EFFECT_PLATFORM_INTEGRATION_PLAN.md b/EFFECT_PLATFORM_INTEGRATION_PLAN.md new file mode 100644 index 0000000000..b2078e17f6 --- /dev/null +++ b/EFFECT_PLATFORM_INTEGRATION_PLAN.md @@ -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", + { + message: Schema.String, + code: Schema.String, + } +) {} + +// Unauthorized - 401 +export class Unauthorized extends Schema.TaggedError()( + "Unauthorized", + { + message: Schema.String, + }, + HttpApiSchema.annotations({ status: 401 }) +) {} + +// Forbidden - 403 +export class Forbidden extends Schema.TaggedError()( + "Forbidden", + { + message: Schema.String, + code: Schema.optional(Schema.String), + }, + HttpApiSchema.annotations({ status: 403 }) +) {} + +// Session expired - 401 +export class SessionExpired extends Schema.TaggedError()( + "SessionExpired", + { + message: Schema.String, + }, + HttpApiSchema.annotations({ status: 401 }) +) {} + +// Invalid credentials - 401 +export class InvalidCredentials extends Schema.TaggedError()( + "InvalidCredentials", + { + message: Schema.String, + }, + HttpApiSchema.annotations({ status: 401 }) +) {} + +// User not found - 404 +export class UserNotFound extends Schema.TaggedError()( + "UserNotFound", + { + message: Schema.String, + }, + HttpApiSchema.annotations({ status: 404 }) +) {} + +// Email already exists - 409 +export class EmailAlreadyExists extends Schema.TaggedError()( + "EmailAlreadyExists", + { + message: Schema.String, + }, + HttpApiSchema.annotations({ status: 409 }) +) {} + +// Rate limited - 429 +export class RateLimited extends Schema.TaggedError()( + "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["$Infer"]["Session"]["user"] +export type InferSession> = A["$Infer"]["Session"]["session"] + +// Create typed session context for a specific auth instance +export const makeSessionContext = >() => { + class CurrentUser extends Context.Tag("@better-auth/effect/CurrentUser")< + CurrentUser, + InferUser + >() {} + + class CurrentSession extends Context.Tag("@better-auth/effect/CurrentSession")< + CurrentSession, + InferSession + >() {} + + // Combined session data + class AuthSession extends Context.Tag("@better-auth/effect/AuthSession")< + AuthSession, + { + readonly user: InferUser + readonly session: InferSession + } + >() {} + + return { CurrentUser, CurrentSession, AuthSession } +} + +// Default exports for simple usage +export class CurrentUser extends Context.Tag("@better-auth/effect/CurrentUser")< + CurrentUser, + Record +>() {} + +export class CurrentSession extends Context.Tag("@better-auth/effect/CurrentSession")< + CurrentSession, + Record +>() {} +``` + +### 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 = Auth> { + 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 + + // List sessions + readonly listSessions: ( + headers: Headers + ) => Effect.Effect< + Array, + Errors.Unauthorized + > + + // Revoke session + readonly revokeSession: ( + token: string, + headers: Headers + ) => Effect.Effect + + // Handler for mounting + readonly handler: (request: Request) => Promise +} + +// Service Tag +export class BetterAuth extends Context.Tag("@better-auth/effect/BetterAuth")< + BetterAuth, + BetterAuthService +>() {} + +// Create the service implementation +export const make = ( + options: Options +): Effect.Effect>> => + 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> + }) + +// Layer constructor +export const layer = ( + options: Options +): Layer.Layer => + 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()( + "@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 = + 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()( + "@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 = + 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> => + 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 | undefined +>() {} + +export class OptionalAuthMiddleware extends HttpApiMiddleware.Tag()( + "@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")({ + id: Schema.String, + email: Schema.String, + name: Schema.String, +}) {} + +class CreatePostInput extends Schema.Class("CreatePostInput")({ + title: Schema.String, + content: Schema.String, +}) {} + +class Post extends Schema.Class("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. diff --git a/docs/content/docs/integrations/effect-platform.mdx b/docs/content/docs/integrations/effect-platform.mdx new file mode 100644 index 0000000000..2571e878a4 --- /dev/null +++ b/docs/content/docs/integrations/effect-platform.mdx @@ -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")({ + 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() + +// 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")({ + id: Schema.String, + email: Schema.String, + name: Schema.String, +}) {} + +class SessionInfo extends Schema.Class("SessionInfo")({ + id: Schema.String, + expiresAt: Schema.Date, +}) {} + +class Greeting extends Schema.Class("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 diff --git a/packages/effect/README.md b/packages/effect/README.md new file mode 100644 index 0000000000..4ae630d560 --- /dev/null +++ b/packages/effect/README.md @@ -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")({ + 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() + +// 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", + { + failure: Unauthorized, + provides: CurrentUser, + security: { + cookie: HttpApiSecurity.apiKey({ + in: "cookie", + key: "my-custom-session-cookie", + }), + }, + } +) {} +``` + +## License + +MIT diff --git a/packages/effect/package.json b/packages/effect/package.json new file mode 100644 index 0000000000..cfca672fe5 --- /dev/null +++ b/packages/effect/package.json @@ -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" + } +} diff --git a/packages/effect/src/BetterAuth.ts b/packages/effect/src/BetterAuth.ts new file mode 100644 index 0000000000..6bd69c652c --- /dev/null +++ b/packages/effect/src/BetterAuth.ts @@ -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 = Auth> { + /** + * 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; session: InferSession }, + 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; session: InferSession }, + 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; session: InferSession }, + Errors.EmailAlreadyExists | Errors.RateLimited + >; + + /** + * Sign out the current session. + * + * @example + * ```typescript + * yield* betterAuth.signOut(request.headers) + * ``` + */ + readonly signOut: (headers: Headers) => Effect.Effect; + + /** + * List all active sessions for the current user. + * + * @example + * ```typescript + * const sessions = yield* betterAuth.listSessions(request.headers) + * ``` + */ + readonly listSessions: ( + headers: Headers + ) => Effect.Effect>, Errors.Unauthorized>; + + /** + * Revoke a specific session by token. + * + * @example + * ```typescript + * yield* betterAuth.revokeSession(sessionToken, request.headers) + * ``` + */ + readonly revokeSession: ( + token: string, + headers: Headers + ) => Effect.Effect; + + /** + * Revoke all sessions except the current one. + * + * @example + * ```typescript + * yield* betterAuth.revokeOtherSessions(request.headers) + * ``` + */ + readonly revokeOtherSessions: ( + headers: Headers + ) => Effect.Effect; + + /** + * The raw request handler for mounting. + * Use this with HttpRouter.mountApp or similar. + */ + readonly handler: (request: Request) => Promise; +} + +// ============================================================================ +// 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: Options +): Effect.Effect>> => + Effect.sync(() => { + const auth = betterAuth(options); + + const service: BetterAuthService> = { + auth: auth as 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, + }; + + 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: Options +): Layer.Layer => 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 = >( + auth: A +): Layer.Layer => + 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; diff --git a/packages/effect/src/Errors.ts b/packages/effect/src/Errors.ts new file mode 100644 index 0000000000..0f67cc47fe --- /dev/null +++ b/packages/effect/src/Errors.ts @@ -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", { + message: Schema.String, + code: Schema.String, +}) {} + +/** + * Unauthorized error - returned when no valid session exists + * HTTP Status: 401 + */ +export class Unauthorized extends Schema.TaggedError()( + "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", + { + 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", + { + 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", + { + message: Schema.String, + }, + HttpApiSchema.annotations({ status: 401 }) +) {} + +/** + * User not found error + * HTTP Status: 404 + */ +export class UserNotFound extends Schema.TaggedError()( + "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", + { + 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", + { + 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", + { + 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; diff --git a/packages/effect/src/HttpApi.ts b/packages/effect/src/HttpApi.ts new file mode 100644 index 0000000000..2dc48b941f --- /dev/null +++ b/packages/effect/src/HttpApi.ts @@ -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()( + "@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 = + 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()( + "@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()( + "@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()( + "@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, + 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; +}): HttpApp.Default => + 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 +> => + 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 +); diff --git a/packages/effect/src/Session.ts b/packages/effect/src/Session.ts new file mode 100644 index 0000000000..646cd61ff2 --- /dev/null +++ b/packages/effect/src/Session.ts @@ -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["$Infer"]["Session"]["user"]; + +/** + * Infer the Session type from a Better Auth instance + */ +export type InferSession> = + A["$Infer"]["Session"]["session"]; + +/** + * Infer the full session data (user + session) from a Better Auth instance + */ +export type InferSessionData> = { + user: InferUser; + session: InferSession; +}; + +// ============================================================================ +// 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() + * + * // Now CurrentUser.Type is exactly your user type + * ``` + */ +export const makeSessionContext = >() => { + class TypedCurrentUser extends Context.Tag( + "@better-auth/effect/CurrentUser" + )>() {} + + class TypedCurrentSession extends Context.Tag( + "@better-auth/effect/CurrentSession" + )>() {} + + class TypedAuthSession extends Context.Tag( + "@better-auth/effect/AuthSession" + )< + TypedAuthSession, + { + readonly user: InferUser; + readonly session: InferSession; + } + >() {} + + 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" +)() {} diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts new file mode 100644 index 0000000000..659fd3c5df --- /dev/null +++ b/packages/effect/src/index.ts @@ -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"; diff --git a/packages/effect/test/BetterAuth.test.ts b/packages/effect/test/BetterAuth.test.ts new file mode 100644 index 0000000000..d781d5b6e8 --- /dev/null +++ b/packages/effect/test/BetterAuth.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json new file mode 100644 index 0000000000..145f501f45 --- /dev/null +++ b/packages/effect/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/effect/tsdown.config.ts b/packages/effect/tsdown.config.ts new file mode 100644 index 0000000000..4a5d25d854 --- /dev/null +++ b/packages/effect/tsdown.config.ts @@ -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"], +}); diff --git a/packages/effect/vitest.config.ts b/packages/effect/vitest.config.ts new file mode 100644 index 0000000000..3f824fb954 --- /dev/null +++ b/packages/effect/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +});