From 9d4f2464048af457f3cc859c246393d2bc4d9d37 Mon Sep 17 00:00:00 2001 From: Matthew Aylward Date: Mon, 13 Jul 2026 12:51:11 +0200 Subject: [PATCH] feat(better-auth): add Effect-native SDK for the better-auth HTTP API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @distilled.cloud/better-auth — a client for a self-hosted better-auth server's core HTTP API (email/password auth, sessions, social account linking, account management) with exhaustive error typing and retry policies, following the shared sdk-core client/traits/errors conventions. - 22 core operations (sign-up/in/out, get/list/revoke sessions, password reset + change, email verification/change, update/delete user, list accounts, link/unlink social, ok) - Session-token bearer credentials via `layer()` / `CredentialsFromEnv` - Hermetic tests: boot a real in-process better-auth server (memory adapter + bearer plugin), no external credentials - Registered in root tsconfig, README, AGENTS.md, and CI (test.yml) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RSaEXHU2Qe2PUq55gYQ2Sc --- .github/workflows/test.yml | 23 ++++ AGENTS.md | 1 + README.md | 1 + bun.lock | 97 ++++++++++--- packages/better-auth/README.md | 74 ++++++++++ packages/better-auth/package.json | 91 +++++++++++++ packages/better-auth/src/category.ts | 4 + packages/better-auth/src/client.ts | 87 ++++++++++++ packages/better-auth/src/credentials.ts | 105 ++++++++++++++ packages/better-auth/src/errors.ts | 56 ++++++++ packages/better-auth/src/index.ts | 33 +++++ .../better-auth/src/operations/changeEmail.ts | 42 ++++++ .../src/operations/changePassword.ts | 45 ++++++ .../better-auth/src/operations/deleteUser.ts | 45 ++++++ .../better-auth/src/operations/getSession.ts | 43 ++++++ packages/better-auth/src/operations/index.ts | 22 +++ .../better-auth/src/operations/linkSocial.ts | 66 +++++++++ .../src/operations/listAccounts.ts | 29 ++++ .../src/operations/listSessions.ts | 28 ++++ packages/better-auth/src/operations/ok.ts | 27 ++++ .../src/operations/requestPasswordReset.ts | 42 ++++++ .../src/operations/resetPassword.ts | 39 ++++++ .../src/operations/revokeOtherSessions.ts | 29 ++++ .../src/operations/revokeSession.ts | 35 +++++ .../src/operations/revokeSessions.ts | 29 ++++ .../src/operations/sendVerificationEmail.ts | 38 ++++++ .../better-auth/src/operations/signInEmail.ts | 54 ++++++++ .../src/operations/signInSocial.ts | 76 +++++++++++ .../better-auth/src/operations/signOut.ts | 30 ++++ .../better-auth/src/operations/signUpEmail.ts | 54 ++++++++ .../src/operations/unlinkAccount.ts | 39 ++++++ .../better-auth/src/operations/updateUser.ts | 39 ++++++ .../better-auth/src/operations/verifyEmail.ts | 42 ++++++ .../src/operations/verifyPassword.ts | 36 +++++ packages/better-auth/src/retry.ts | 57 ++++++++ packages/better-auth/src/schemas.ts | 69 ++++++++++ packages/better-auth/src/sensitive.ts | 4 + packages/better-auth/src/traits.ts | 4 + .../better-auth/tests/email-password.test.ts | 128 ++++++++++++++++++ packages/better-auth/tests/setup.ts | 117 ++++++++++++++++ packages/better-auth/tsconfig.json | 15 ++ packages/better-auth/vitest.config.ts | 12 ++ tsconfig.json | 1 + 43 files changed, 1888 insertions(+), 20 deletions(-) create mode 100644 packages/better-auth/README.md create mode 100644 packages/better-auth/package.json create mode 100644 packages/better-auth/src/category.ts create mode 100644 packages/better-auth/src/client.ts create mode 100644 packages/better-auth/src/credentials.ts create mode 100644 packages/better-auth/src/errors.ts create mode 100644 packages/better-auth/src/index.ts create mode 100644 packages/better-auth/src/operations/changeEmail.ts create mode 100644 packages/better-auth/src/operations/changePassword.ts create mode 100644 packages/better-auth/src/operations/deleteUser.ts create mode 100644 packages/better-auth/src/operations/getSession.ts create mode 100644 packages/better-auth/src/operations/index.ts create mode 100644 packages/better-auth/src/operations/linkSocial.ts create mode 100644 packages/better-auth/src/operations/listAccounts.ts create mode 100644 packages/better-auth/src/operations/listSessions.ts create mode 100644 packages/better-auth/src/operations/ok.ts create mode 100644 packages/better-auth/src/operations/requestPasswordReset.ts create mode 100644 packages/better-auth/src/operations/resetPassword.ts create mode 100644 packages/better-auth/src/operations/revokeOtherSessions.ts create mode 100644 packages/better-auth/src/operations/revokeSession.ts create mode 100644 packages/better-auth/src/operations/revokeSessions.ts create mode 100644 packages/better-auth/src/operations/sendVerificationEmail.ts create mode 100644 packages/better-auth/src/operations/signInEmail.ts create mode 100644 packages/better-auth/src/operations/signInSocial.ts create mode 100644 packages/better-auth/src/operations/signOut.ts create mode 100644 packages/better-auth/src/operations/signUpEmail.ts create mode 100644 packages/better-auth/src/operations/unlinkAccount.ts create mode 100644 packages/better-auth/src/operations/updateUser.ts create mode 100644 packages/better-auth/src/operations/verifyEmail.ts create mode 100644 packages/better-auth/src/operations/verifyPassword.ts create mode 100644 packages/better-auth/src/retry.ts create mode 100644 packages/better-auth/src/schemas.ts create mode 100644 packages/better-auth/src/sensitive.ts create mode 100644 packages/better-auth/src/traits.ts create mode 100644 packages/better-auth/tests/email-password.test.ts create mode 100644 packages/better-auth/tests/setup.ts create mode 100644 packages/better-auth/tsconfig.json create mode 100644 packages/better-auth/vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30c3bc584b..5335171739 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,7 @@ jobs: typesense: ${{ steps.force.outputs.all || steps.changes.outputs.typesense }} workos: ${{ steps.force.outputs.all || steps.changes.outputs.workos }} expo-eas: ${{ steps.force.outputs.all || steps.changes.outputs.expo-eas }} + better-auth: ${{ steps.force.outputs.all || steps.changes.outputs.better-auth }} steps: - id: force if: contains(github.event.pull_request.labels.*.name, 'force-ci') @@ -110,6 +111,9 @@ jobs: expo-eas: - 'packages/expo-eas/**' - 'packages/core/**' + better-auth: + - 'packages/better-auth/**' + - 'packages/core/**' ci-core: needs: detect-changes @@ -124,6 +128,25 @@ jobs: - run: bun run check working-directory: packages/core + ci-better-auth: + needs: detect-changes + if: needs.detect-changes.outputs.better-auth == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bun run build + working-directory: packages/core + - run: bun run check + working-directory: packages/better-auth + # Tests boot an in-process better-auth server (memory adapter) — no + # external credentials or network access required. + - run: bun run test + working-directory: packages/better-auth + ci-aws: needs: detect-changes if: needs.detect-changes.outputs.aws == 'true' diff --git a/AGENTS.md b/AGENTS.md index 710c196248..37d83f1afc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ distilled/ ├── packages/ │ ├── core/ # @distilled.cloud/sdk-core — shared client, traits, errors, categories │ ├── aws/ # @distilled.cloud/aws — AWS SDK from Smithy models +│ ├── better-auth/ # @distilled.cloud/better-auth — better-auth HTTP API client (hand-written, no spec) │ ├── cloudflare/ # @distilled.cloud/cloudflare — Cloudflare SDK from TypeScript SDK │ ├── coinbase/ # @distilled.cloud/coinbase — Coinbase CDP SDK from OpenAPI spec │ ├── fly-io/ # @distilled.cloud/fly-io — Fly.io SDK from OpenAPI spec diff --git a/README.md b/README.md index a6207ab6a7..0f27f8c0c4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ For larger SDKs its very likely you hit 5hr limits on claude, you can just run t |---------|-------------| | [`@distilled.cloud/core`](./packages/core) | Shared client, traits, errors, and categories | | [`@distilled.cloud/aws`](./packages/aws) | AWS SDK from Smithy models (S3, Lambda, DynamoDB, 200+ services) | +| [`@distilled.cloud/better-auth`](./packages/better-auth) | better-auth SDK — Effect-native client for a self-hosted better-auth HTTP API | | [`@distilled.cloud/cloudflare`](./packages/cloudflare) | Cloudflare SDK (Workers, R2, KV, D1, Queues, DNS) | | [`@distilled.cloud/coinbase`](./packages/coinbase) | Coinbase CDP SDK (EVM/Solana wallets, swaps, faucets, onramp) | | [`@distilled.cloud/fly-io`](./packages/fly-io) | Fly.io SDK from OpenAPI spec | diff --git a/bun.lock b/bun.lock index 0444956dc0..4c5c3959f4 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/aws": { "name": "@distilled.cloud/aws", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@aws-crypto/crc32": "catalog:", "@aws-crypto/util": "catalog:", @@ -54,7 +54,7 @@ }, "packages/axiom": { "name": "@distilled.cloud/axiom", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -70,7 +70,7 @@ }, "packages/azure": { "name": "@distilled.cloud/azure", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -84,9 +84,26 @@ "effect": "catalog:", }, }, + "packages/better-auth": { + "name": "@distilled.cloud/better-auth", + "version": "0.0.0", + "dependencies": { + "@distilled.cloud/core": "workspace:*", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "better-auth": "^1.3.0", + "dotenv": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "effect": "catalog:", + }, + }, "packages/cloudflare": { "name": "@distilled.cloud/cloudflare", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -105,7 +122,7 @@ }, "packages/coinbase": { "name": "@distilled.cloud/coinbase", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -122,7 +139,7 @@ }, "packages/core": { "name": "@distilled.cloud/core", - "version": "0.28.2", + "version": "0.29.0", "devDependencies": { "@types/bun": "catalog:", "@types/node": "catalog:", @@ -135,7 +152,7 @@ }, "packages/expo-eas": { "name": "@distilled.cloud/expo-eas", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -151,7 +168,7 @@ }, "packages/fly-io": { "name": "@distilled.cloud/fly-io", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -167,7 +184,7 @@ }, "packages/gcp": { "name": "@distilled.cloud/gcp", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -183,7 +200,7 @@ }, "packages/kubernetes": { "name": "@distilled.cloud/kubernetes", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -199,7 +216,7 @@ }, "packages/mongodb-atlas": { "name": "@distilled.cloud/mongodb-atlas", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -215,7 +232,7 @@ }, "packages/neon": { "name": "@distilled.cloud/neon", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -231,7 +248,7 @@ }, "packages/planetscale": { "name": "@distilled.cloud/planetscale", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -247,7 +264,7 @@ }, "packages/posthog": { "name": "@distilled.cloud/posthog", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -265,7 +282,7 @@ }, "packages/prisma-postgres": { "name": "@distilled.cloud/prisma-postgres", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -281,7 +298,7 @@ }, "packages/stripe": { "name": "@distilled.cloud/stripe", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -297,7 +314,7 @@ }, "packages/supabase": { "name": "@distilled.cloud/supabase", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -313,7 +330,7 @@ }, "packages/turso": { "name": "@distilled.cloud/turso", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -329,7 +346,7 @@ }, "packages/typesense": { "name": "@distilled.cloud/typesense", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -346,7 +363,7 @@ }, "packages/workos": { "name": "@distilled.cloud/workos", - "version": "0.28.2", + "version": "0.29.0", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -468,12 +485,32 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], + "@better-auth/core": ["@better-auth/core@1.6.23", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2" } }, "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + "@distilled.cloud/aws": ["@distilled.cloud/aws@workspace:packages/aws"], "@distilled.cloud/axiom": ["@distilled.cloud/axiom@workspace:packages/axiom"], "@distilled.cloud/azure": ["@distilled.cloud/azure@workspace:packages/azure"], + "@distilled.cloud/better-auth": ["@distilled.cloud/better-auth@workspace:packages/better-auth"], + "@distilled.cloud/cloudflare": ["@distilled.cloud/cloudflare@workspace:packages/cloudflare"], "@distilled.cloud/coinbase": ["@distilled.cloud/coinbase@workspace:packages/coinbase"], @@ -620,6 +657,12 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], + + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.36.0", "", { "os": "android", "cpu": "arm" }, "sha512-Z4yVHJWx/swHHjtr0dXrBZb6LxS+qNz1qdza222mWwPTUK4L790+5i3LTgjx3KYGBzcYpjaiZBw4vOx94dH7MQ=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.36.0", "", { "os": "android", "cpu": "arm64" }, "sha512-3ElCJRFNPQl7jexf2CAa9XmAm8eC5JPrIDSjc9jSchkVSFTEqyL0NtZinBB2h1a4i4JgP1oGl/5G5n8YR4FN8Q=="], @@ -962,6 +1005,10 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "better-auth": ["better-auth@1.6.23", "", { "dependencies": { "@better-auth/core": "1.6.23", "@better-auth/drizzle-adapter": "1.6.23", "@better-auth/kysely-adapter": "1.6.23", "@better-auth/memory-adapter": "1.6.23", "@better-auth/mongo-adapter": "1.6.23", "@better-auth/prisma-adapter": "1.6.23", "@better-auth/telemetry": "1.6.23", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ=="], + + "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], @@ -1158,10 +1205,14 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "kysely": ["kysely@0.29.3", "", {}, "sha512-VHtBdW6XB/pgoTSqraM3UAa2rYoYdNXqnNPpX+8XXP+cwYbVEFuAp3HyPt1vpNfU9l7Y2kpUrA9QDPsy8uUqOQ=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], @@ -1212,6 +1263,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanostores": ["nanostores@1.4.0", "", {}, "sha512-i0tloweeudshAEuddpDxcg9Ik6pkPfVsHIgKyf143JrgG7/MOh0+q7BypdLXZPoOP7fOYt1eTcwGkyiVmhJFkA=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], @@ -1290,6 +1343,8 @@ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1298,6 +1353,8 @@ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], diff --git a/packages/better-auth/README.md b/packages/better-auth/README.md new file mode 100644 index 0000000000..5386083080 --- /dev/null +++ b/packages/better-auth/README.md @@ -0,0 +1,74 @@ +# @distilled.cloud/better-auth + +Effect-native client for a self-hosted [better-auth](https://better-auth.com) server's HTTP API — email/password auth, sessions, social account linking, and account management — with exhaustive error typing and retry policies. + +This is a **client** for a better-auth server you run; it does not depend on or embed better-auth itself. It talks to the handler's HTTP API (default mount path `/api/auth`). + +## Installation + +```bash +npm install @distilled.cloud/better-auth effect +``` + +## Quick Start + +Sign in to obtain a session token, then re-provide it as credentials for authenticated calls. The token is sent as `Authorization: Bearer `, which the server resolves when the [`bearer`](https://better-auth.com/docs/plugins/bearer) plugin is enabled. + +```typescript +import * as Effect from "effect/Effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as BetterAuth from "@distilled.cloud/better-auth"; + +const baseUrl = "https://app.example.com/api/auth"; + +const program = Effect.gen(function* () { + // Public call — no token needed. + const { token } = yield* BetterAuth.signInEmail({ + email: "ada@example.com", + password: "correct-horse-battery-staple", + }); + + // Authenticated call — provide the session token. + const session = yield* BetterAuth.getSession({}).pipe( + Effect.provide(BetterAuth.layer({ baseUrl, token })), + ); + + return session; // { session, user } | null +}).pipe( + Effect.provide(BetterAuth.layer({ baseUrl })), + Effect.provide(FetchHttpClient.layer), +); +``` + +Errors are typed and matchable: + +```typescript +BetterAuth.signInEmail({ email, password }).pipe( + Effect.catch("Unauthorized", () => Effect.succeed(null)), +); +``` + +## Configuration + +Credentials come from the environment via `CredentialsFromEnv`, or are built directly with `layer()`: + +```bash +BETTER_AUTH_URL=https://app.example.com/api/auth # base URL incl. mount path +BETTER_AUTH_TOKEN=... # optional session token +``` + +`BETTER_AUTH_URL` defaults to `http://localhost:3000/api/auth`. + +## Operations + +Authentication: `signUpEmail`, `signInEmail`, `signInSocial`, `signOut`. +Sessions: `getSession`, `listSessions`, `revokeSession`, `revokeSessions`, `revokeOtherSessions`. +Password & email: `requestPasswordReset`, `resetPassword`, `changePassword`, `verifyPassword`, `sendVerificationEmail`, `verifyEmail`, `changeEmail`. +Account: `updateUser`, `deleteUser`, `listAccounts`, `linkSocial`, `unlinkAccount`. +Health: `ok`. + +Only the core endpoints (present with email/password + base session/social handling) are covered — plugin routes (`admin`, `organization`, `apiKey`, `twoFactor`, `magicLink`, `passkey`, ...) are not. + +## License + +Apache-2.0 diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json new file mode 100644 index 0000000000..8ce7057813 --- /dev/null +++ b/packages/better-auth/package.json @@ -0,0 +1,91 @@ +{ + "name": "@distilled.cloud/better-auth", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "https://github.com/alchemy-run/distilled", + "directory": "packages/better-auth" + }, + "license": "Apache-2.0", + "type": "module", + "sideEffects": false, + "module": "src/index.ts", + "files": [ + "lib", + "src" + ], + "exports": { + ".": { + "types": "./lib/index.d.ts", + "bun": "./src/index.ts", + "default": "./lib/index.js" + }, + "./Category": { + "types": "./lib/category.d.ts", + "bun": "./src/category.ts", + "default": "./lib/category.js" + }, + "./Client": { + "types": "./lib/client.d.ts", + "bun": "./src/client.ts", + "default": "./lib/client.js" + }, + "./Credentials": { + "types": "./lib/credentials.d.ts", + "bun": "./src/credentials.ts", + "default": "./lib/credentials.js" + }, + "./Errors": { + "types": "./lib/errors.d.ts", + "bun": "./src/errors.ts", + "default": "./lib/errors.js" + }, + "./Operations": { + "types": "./lib/operations/index.d.ts", + "bun": "./src/operations/index.ts", + "default": "./lib/operations/index.js" + }, + "./Retry": { + "types": "./lib/retry.d.ts", + "bun": "./src/retry.ts", + "default": "./lib/retry.js" + }, + "./Schemas": { + "types": "./lib/schemas.d.ts", + "bun": "./src/schemas.ts", + "default": "./lib/schemas.js" + }, + "./Sensitive": { + "types": "./lib/sensitive.d.ts", + "bun": "./src/sensitive.ts", + "default": "./lib/sensitive.js" + }, + "./Traits": { + "types": "./lib/traits.d.ts", + "bun": "./src/traits.ts", + "default": "./lib/traits.js" + } + }, + "scripts": { + "typecheck": "tsc", + "build": "tsc -b", + "fmt": "oxfmt --write src", + "lint": "oxlint --fix src", + "check": "tsc && oxlint src && oxfmt --check src", + "test": "bunx vitest run test --exclude specs --passWithNoTests", + "publish:npm": "bun run build && bun publish --access public" + }, + "dependencies": { + "@distilled.cloud/core": "workspace:*" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "better-auth": "^1.3.0", + "dotenv": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" + } +} diff --git a/packages/better-auth/src/category.ts b/packages/better-auth/src/category.ts new file mode 100644 index 0000000000..a09ac2ecca --- /dev/null +++ b/packages/better-auth/src/category.ts @@ -0,0 +1,4 @@ +/** + * Re-export the shared category system from sdk-core. + */ +export * from "@distilled.cloud/core/category"; diff --git a/packages/better-auth/src/client.ts b/packages/better-auth/src/client.ts new file mode 100644 index 0000000000..c9b0da991f --- /dev/null +++ b/packages/better-auth/src/client.ts @@ -0,0 +1,87 @@ +/** + * better-auth API client. + * + * Wraps the shared REST client from sdk-core with better-auth-specific error + * matching and credential handling. The base URL is the origin + mount path + * of your self-hosted better-auth handler; authenticated calls attach the + * session token as `Authorization: Bearer ` (resolved server-side by + * the `bearer` plugin). + */ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { makeAPI } from "@distilled.cloud/core/client"; +import { parseRetryAfterForStatus } from "@distilled.cloud/core/retry-after"; +import { Retry } from "./retry.ts"; +import { + HTTP_STATUS_MAP, + UnknownBetterAuthError, + BetterAuthParseError, +} from "./errors.ts"; +import { Credentials } from "./credentials.ts"; + +// Re-export for backwards compatibility with generated imports. +export { UnknownBetterAuthError } from "./errors.ts"; + +/** + * better-auth API error response. + * + * better-auth (via better-call) serializes API errors as a JSON object with a + * human-readable `message`, and usually a machine-readable `code` + * (e.g. `INVALID_EMAIL_OR_PASSWORD`). The `code`/`message` keys may be + * absent for infrastructure-level failures, so both are optional. + */ +const ApiErrorResponse = Schema.Struct({ + message: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), +}); + +/** + * Match a better-auth error response to a typed error class based on HTTP + * status. Known statuses map through {@link HTTP_STATUS_MAP}; anything else + * surfaces as {@link UnknownBetterAuthError} carrying the raw `code`/`body`. + */ +const matchError = ( + status: number, + errorBody: unknown, + _errors?: readonly unknown[], + headers?: Record, +): Effect.Effect => { + let message = ""; + let code: string | undefined; + try { + const parsed = Schema.decodeUnknownSync(ApiErrorResponse)(errorBody); + message = parsed.message ?? ""; + code = parsed.code; + } catch { + if (typeof errorBody === "string") message = errorBody; + } + + const ErrorClass = (HTTP_STATUS_MAP as any)[status]; + if (ErrorClass) { + return Effect.fail( + new ErrorClass({ + message: message || code || `HTTP ${status}`, + retryAfter: parseRetryAfterForStatus(status, headers), + }), + ); + } + return Effect.fail( + new UnknownBetterAuthError({ code, message, body: errorBody }), + ); +}; + +/** + * better-auth API client. + */ +export const API = makeAPI({ + credentials: Credentials as any, + getBaseUrl: (creds: any) => creds.baseUrl, + getAuthHeaders: (creds: any): Record => + creds.token === undefined + ? {} + : { Authorization: `Bearer ${Redacted.value(creds.token)}` }, + matchError, + ParseError: BetterAuthParseError as any, + retry: Retry as any, +}); diff --git a/packages/better-auth/src/credentials.ts b/packages/better-auth/src/credentials.ts new file mode 100644 index 0000000000..2e0bf22aea --- /dev/null +++ b/packages/better-auth/src/credentials.ts @@ -0,0 +1,105 @@ +/** + * better-auth credentials. + * + * Unlike a hosted cloud API, better-auth is self-hosted: the base URL is the + * origin + mount path of *your* deployed auth handler (default mount path + * `/api/auth`), e.g. `https://app.example.com/api/auth`. + * + * Authentication is session-based. Public endpoints (`signInEmail`, + * `signUpEmail`, `requestPasswordReset`, `ok`, ...) need no token. For + * session-protected endpoints (`getSession`, `signOut`, `updateUser`, + * `listSessions`, ...) provide the session `token` returned in the body of + * `signInEmail`/`signUpEmail`; it is sent as `Authorization: Bearer `, + * which the server resolves when the `bearer` plugin is enabled. + * + * Because the token is obtained *at runtime* (after signing in), the usual + * flow is to sign in with an unauthenticated layer, then re-provide a + * token-bearing layer built with {@link layer} for the authenticated calls. + * + * @example + * ```ts + * import * as BetterAuth from "@distilled.cloud/better-auth"; + * + * const authed = BetterAuth.layer({ + * baseUrl: "https://app.example.com/api/auth", + * token: sessionToken, + * }); + * ``` + */ +import { ConfigError } from "@distilled.cloud/core/errors"; +import * as EffectConfig from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; + +/** Default base URL — a local better-auth dev server mounted at `/api/auth`. */ +export const DEFAULT_BASE_URL = "http://localhost:3000/api/auth"; + +export interface Config { + /** Base URL of the better-auth handler, including its mount path. */ + readonly baseUrl: string; + /** Optional session token for authenticated endpoints (sent as a bearer). */ + readonly token?: Redacted.Redacted; +} + +export class Credentials extends Context.Service< + Credentials, + Effect.Effect +>()("BetterAuthCredentials") {} + +/** + * Build a credentials Layer directly from a base URL and optional session + * token. Use this to inject a token captured from a sign-in response. + */ +export const layer = (config: { + baseUrl: string; + token?: string | Redacted.Redacted; +}): Layer.Layer => + Layer.succeed( + Credentials, + Effect.succeed({ + baseUrl: config.baseUrl, + token: + config.token === undefined + ? undefined + : Redacted.isRedacted(config.token) + ? config.token + : Redacted.make(config.token), + }), + ); + +const envConfig = EffectConfig.all({ + baseUrl: EffectConfig.string("BETTER_AUTH_URL").pipe( + EffectConfig.withDefault(DEFAULT_BASE_URL), + ), + token: EffectConfig.option(EffectConfig.string("BETTER_AUTH_TOKEN")), +}); + +/** + * Credentials from the environment. + * + * - `BETTER_AUTH_URL` — base URL of the auth handler (default + * `http://localhost:3000/api/auth`). + * - `BETTER_AUTH_TOKEN` — optional session token for authenticated calls. + */ +export const CredentialsFromEnv = Layer.succeed( + Credentials, + envConfig.pipe( + Effect.mapError( + () => + new ConfigError({ + message: "BETTER_AUTH_URL environment variable is required", + }), + ), + Effect.map(({ baseUrl, token }) => { + const raw = Option.getOrUndefined(token); + return { + baseUrl, + token: raw === undefined ? undefined : Redacted.make(raw), + }; + }), + Effect.orDie, + ), +); diff --git a/packages/better-auth/src/errors.ts b/packages/better-auth/src/errors.ts new file mode 100644 index 0000000000..1b82dbace0 --- /dev/null +++ b/packages/better-auth/src/errors.ts @@ -0,0 +1,56 @@ +/** + * better-auth-specific error types. + * + * Re-exports the common HTTP errors from sdk-core and adds better-auth's + * own error-matching types. better-auth serializes API errors as a JSON + * object with a human-readable `message` and (usually) a machine-readable + * `code` (e.g. `INVALID_EMAIL_OR_PASSWORD`, `USER_ALREADY_EXISTS`), returned + * under a conventional HTTP status code. The status is mapped to a typed + * error class in `client.ts`; unrecognized shapes fall back to + * {@link UnknownBetterAuthError}. + */ +export { + BadGateway, + BadRequest, + Conflict, + ConfigError, + Forbidden, + GatewayTimeout, + InternalServerError, + Locked, + NotFound, + ServiceUnavailable, + TooManyRequests, + Unauthorized, + UnprocessableEntity, + HTTP_STATUS_MAP, + DEFAULT_ERRORS, + API_ERRORS, +} from "@distilled.cloud/core/errors"; +export type { DefaultErrors } from "@distilled.cloud/core/errors"; + +import * as Schema from "effect/Schema"; +import * as Category from "@distilled.cloud/core/category"; + +/** + * Returned when a better-auth error response does not map to a known HTTP + * status error class. Carries the raw `code`/`message` better-auth reported + * plus the full response `body` for debugging. + */ +export class UnknownBetterAuthError extends Schema.TaggedErrorClass()( + "UnknownBetterAuthError", + { + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + body: Schema.Unknown, + }, +).pipe(Category.withServerError) {} + +/** Wraps a schema decode failure on a better-auth response body. */ +export class BetterAuthParseError extends Schema.TaggedErrorClass()( + "BetterAuthParseError", + { + body: Schema.Unknown, + cause: Schema.Unknown, + }, +).pipe(Category.withParseError) {} diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts new file mode 100644 index 0000000000..24b1ae967a --- /dev/null +++ b/packages/better-auth/src/index.ts @@ -0,0 +1,33 @@ +/** + * better-auth SDK for Effect. + * + * An Effect-native client for a self-hosted [better-auth](https://better-auth.com) + * server's HTTP API — email/password auth, sessions, social linking, and + * account management — with exhaustive error typing and retry policies. + * + * @example + * ```ts + * import * as BetterAuth from "@distilled.cloud/better-auth"; + * import * as Effect from "effect/Effect"; + * + * const program = Effect.gen(function* () { + * const { token } = yield* BetterAuth.signInEmail({ + * email: "a@b.com", + * password: "correct-horse", + * }); + * const session = yield* BetterAuth.getSession({}).pipe( + * Effect.provide(BetterAuth.layer({ baseUrl, token })), + * ); + * return session; + * }); + * ``` + */ +export * from "./credentials.ts"; +export * as Category from "./category.ts"; +export * as T from "./traits.ts"; +export * as Retry from "./retry.ts"; +export { API } from "./client.ts"; +export * from "./errors.ts"; +export * from "./schemas.ts"; +export * from "./operations/index.ts"; +export { SensitiveString, SensitiveNullableString } from "./sensitive.ts"; diff --git a/packages/better-auth/src/operations/changeEmail.ts b/packages/better-auth/src/operations/changeEmail.ts new file mode 100644 index 0000000000..c4a940826a --- /dev/null +++ b/packages/better-auth/src/operations/changeEmail.ts @@ -0,0 +1,42 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Unauthorized } from "../errors.ts"; + +// Input Schema +export interface ChangeEmailInput { + newEmail: string; + callbackURL?: string; +} +export const ChangeEmailInput = /*@__PURE__*/ Schema.Struct({ + newEmail: Schema.String, + callbackURL: Schema.optional(Schema.String), +}).pipe( + T.Http({ method: "POST", path: "/change-email" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface ChangeEmailOutput { + status: boolean; + message?: string; +} +export const ChangeEmailOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, + message: Schema.optional(Schema.String), +}) as unknown as Schema.Codec; + +/** + * Change the current user's email address. + * + * Requires a fresh authenticated session. If the current email is verified, a + * confirmation email is sent and the change applies after the user clicks it; + * otherwise it may apply immediately. + * + * @param newEmail - The new email address. + * @param callbackURL - Optional redirect URL for the confirmation flow. + */ +export const changeEmail = /*@__PURE__*/ API.make(() => ({ + inputSchema: ChangeEmailInput, + outputSchema: ChangeEmailOutput, + errors: [BadRequest, Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/changePassword.ts b/packages/better-auth/src/operations/changePassword.ts new file mode 100644 index 0000000000..ea9f539518 --- /dev/null +++ b/packages/better-auth/src/operations/changePassword.ts @@ -0,0 +1,45 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Unauthorized } from "../errors.ts"; +import { User } from "../schemas.ts"; + +// Input Schema +export interface ChangePasswordInput { + newPassword: string; + currentPassword: string; + revokeOtherSessions?: boolean; +} +export const ChangePasswordInput = /*@__PURE__*/ Schema.Struct({ + newPassword: Schema.String, + currentPassword: Schema.String, + revokeOtherSessions: Schema.optional(Schema.Boolean), +}).pipe( + T.Http({ method: "POST", path: "/change-password" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface ChangePasswordOutput { + token: string | null; + user: User; +} +export const ChangePasswordOutput = /*@__PURE__*/ Schema.Struct({ + token: Schema.NullOr(Schema.String), + user: User, +}) as unknown as Schema.Codec; + +/** + * Change the current user's password. + * + * Requires a fresh authenticated session. When `revokeOtherSessions` rotates + * the session, a new `token` is returned (otherwise `null`). + * + * @param newPassword - The new password to set. + * @param currentPassword - The user's current password (verified server-side). + * @param revokeOtherSessions - Optional; sign out all other sessions. + */ +export const changePassword = /*@__PURE__*/ API.make(() => ({ + inputSchema: ChangePasswordInput, + outputSchema: ChangePasswordOutput, + errors: [BadRequest, Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/deleteUser.ts b/packages/better-auth/src/operations/deleteUser.ts new file mode 100644 index 0000000000..230bafab79 --- /dev/null +++ b/packages/better-auth/src/operations/deleteUser.ts @@ -0,0 +1,45 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Unauthorized } from "../errors.ts"; + +// Input Schema +export interface DeleteUserInput { + password?: string; + token?: string; + callbackURL?: string; +} +export const DeleteUserInput = /*@__PURE__*/ Schema.Struct({ + password: Schema.optional(Schema.String), + token: Schema.optional(Schema.String), + callbackURL: Schema.optional(Schema.String), +}).pipe( + T.Http({ method: "POST", path: "/delete-user" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface DeleteUserOutput { + success: boolean; + message?: string; +} +export const DeleteUserOutput = /*@__PURE__*/ Schema.Struct({ + success: Schema.Boolean, + message: Schema.optional(Schema.String), +}) as unknown as Schema.Codec; + +/** + * Delete the current user. + * + * Requires a fresh authenticated session. Depending on server configuration + * the deletion either applies immediately or triggers a confirmation email + * (in which case `message` reflects "Verification email sent"). + * + * @param password - Optional; required when deletion is password-verified. + * @param token - Optional; the verification-flow token. + * @param callbackURL - Optional redirect URL for the confirmation flow. + */ +export const deleteUser = /*@__PURE__*/ API.make(() => ({ + inputSchema: DeleteUserInput, + outputSchema: DeleteUserOutput, + errors: [BadRequest, Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/getSession.ts b/packages/better-auth/src/operations/getSession.ts new file mode 100644 index 0000000000..1509d5a973 --- /dev/null +++ b/packages/better-auth/src/operations/getSession.ts @@ -0,0 +1,43 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { Session, User } from "../schemas.ts"; + +// Input Schema +export interface GetSessionInput { + disableCookieCache?: boolean; + disableRefresh?: boolean; +} +export const GetSessionInput = /*@__PURE__*/ Schema.Struct({ + disableCookieCache: Schema.optional(Schema.Boolean).pipe(T.QueryParam()), + disableRefresh: Schema.optional(Schema.Boolean).pipe(T.QueryParam()), +}).pipe( + T.Http({ method: "GET", path: "/get-session" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface GetSessionOutput { + session: Session; + user: User; +} +export const GetSessionOutput = /*@__PURE__*/ Schema.NullOr( + Schema.Struct({ + session: Session, + user: User, + }), +) as unknown as Schema.Codec; + +/** + * Get the current session. + * + * Reads the session from the request's cookie/bearer. Returns + * `{ session, user }` when authenticated, or `null` when not (this does not + * error on an unauthenticated request). + * + * @param disableCookieCache - Optional; bypass the short-lived cookie cache. + * @param disableRefresh - Optional; do not slide the session expiry. + */ +export const getSession = /*@__PURE__*/ API.make(() => ({ + inputSchema: GetSessionInput, + outputSchema: GetSessionOutput, +})); diff --git a/packages/better-auth/src/operations/index.ts b/packages/better-auth/src/operations/index.ts new file mode 100644 index 0000000000..838ccb3de8 --- /dev/null +++ b/packages/better-auth/src/operations/index.ts @@ -0,0 +1,22 @@ +export * from "./signUpEmail.ts"; +export * from "./signInEmail.ts"; +export * from "./signInSocial.ts"; +export * from "./signOut.ts"; +export * from "./getSession.ts"; +export * from "./listSessions.ts"; +export * from "./revokeSession.ts"; +export * from "./revokeSessions.ts"; +export * from "./revokeOtherSessions.ts"; +export * from "./requestPasswordReset.ts"; +export * from "./resetPassword.ts"; +export * from "./verifyEmail.ts"; +export * from "./sendVerificationEmail.ts"; +export * from "./changePassword.ts"; +export * from "./changeEmail.ts"; +export * from "./updateUser.ts"; +export * from "./deleteUser.ts"; +export * from "./verifyPassword.ts"; +export * from "./listAccounts.ts"; +export * from "./linkSocial.ts"; +export * from "./unlinkAccount.ts"; +export * from "./ok.ts"; diff --git a/packages/better-auth/src/operations/linkSocial.ts b/packages/better-auth/src/operations/linkSocial.ts new file mode 100644 index 0000000000..f8a68e5bd2 --- /dev/null +++ b/packages/better-auth/src/operations/linkSocial.ts @@ -0,0 +1,66 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Unauthorized } from "../errors.ts"; + +// Input Schema +export interface LinkSocialInput { + provider: string; + callbackURL?: string; + errorCallbackURL?: string; + disableRedirect?: boolean; + requestSignUp?: boolean; + scopes?: string[]; + idToken?: { + token: string; + nonce?: string; + accessToken?: string; + refreshToken?: string; + }; +} +export const LinkSocialInput = /*@__PURE__*/ Schema.Struct({ + provider: Schema.String, + callbackURL: Schema.optional(Schema.String), + errorCallbackURL: Schema.optional(Schema.String), + disableRedirect: Schema.optional(Schema.Boolean), + requestSignUp: Schema.optional(Schema.Boolean), + scopes: Schema.optional(Schema.Array(Schema.String)), + idToken: Schema.optional( + Schema.Struct({ + token: Schema.String, + nonce: Schema.optional(Schema.String), + accessToken: Schema.optional(Schema.String), + refreshToken: Schema.optional(Schema.String), + }), + ), +}).pipe( + T.Http({ method: "POST", path: "/link-social" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface LinkSocialOutput { + redirect: boolean; + url?: string; + status?: boolean; +} +export const LinkSocialOutput = /*@__PURE__*/ Schema.Struct({ + redirect: Schema.Boolean, + url: Schema.optional(Schema.String), + status: Schema.optional(Schema.Boolean), +}) as unknown as Schema.Codec; + +/** + * Link a social / OAuth provider to the current user. + * + * Requires an authenticated session. For the redirect flow, navigate the user + * to `url`; the native `idToken` flow links directly. + * + * @param provider - A configured social provider id. + * @param callbackURL - Optional post-link redirect URL. + * @param idToken - Optional; native ID-token flow. + */ +export const linkSocial = /*@__PURE__*/ API.make(() => ({ + inputSchema: LinkSocialInput, + outputSchema: LinkSocialOutput, + errors: [BadRequest, Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/listAccounts.ts b/packages/better-auth/src/operations/listAccounts.ts new file mode 100644 index 0000000000..d26d382648 --- /dev/null +++ b/packages/better-auth/src/operations/listAccounts.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { Unauthorized } from "../errors.ts"; +import { Account } from "../schemas.ts"; + +// Input Schema +export interface ListAccountsInput {} +export const ListAccountsInput = /*@__PURE__*/ Schema.Struct({}).pipe( + T.Http({ method: "GET", path: "/list-accounts" }), +) as unknown as Schema.Codec; + +// Output Schema +export type ListAccountsOutput = Account[]; +export const ListAccountsOutput = /*@__PURE__*/ Schema.Array( + Account, +) as unknown as Schema.Codec; + +/** + * List the current user's linked accounts. + * + * Requires an authenticated session. Returns account metadata only — no + * provider secrets or tokens are exposed. + */ +export const listAccounts = /*@__PURE__*/ API.make(() => ({ + inputSchema: ListAccountsInput, + outputSchema: ListAccountsOutput, + errors: [Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/listSessions.ts b/packages/better-auth/src/operations/listSessions.ts new file mode 100644 index 0000000000..b612625eed --- /dev/null +++ b/packages/better-auth/src/operations/listSessions.ts @@ -0,0 +1,28 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { Unauthorized } from "../errors.ts"; +import { Session } from "../schemas.ts"; + +// Input Schema +export interface ListSessionsInput {} +export const ListSessionsInput = /*@__PURE__*/ Schema.Struct({}).pipe( + T.Http({ method: "GET", path: "/list-sessions" }), +) as unknown as Schema.Codec; + +// Output Schema +export type ListSessionsOutput = Session[]; +export const ListSessionsOutput = /*@__PURE__*/ Schema.Array( + Session, +) as unknown as Schema.Codec; + +/** + * List the current user's active sessions. + * + * Requires an authenticated session. Returns an array of session objects. + */ +export const listSessions = /*@__PURE__*/ API.make(() => ({ + inputSchema: ListSessionsInput, + outputSchema: ListSessionsOutput, + errors: [Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/ok.ts b/packages/better-auth/src/operations/ok.ts new file mode 100644 index 0000000000..444b94a8bd --- /dev/null +++ b/packages/better-auth/src/operations/ok.ts @@ -0,0 +1,27 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; + +// Input Schema +export interface OkInput {} +export const OkInput = /*@__PURE__*/ Schema.Struct({}).pipe( + T.Http({ method: "GET", path: "/ok" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface OkOutput { + ok: boolean; +} +export const OkOutput = /*@__PURE__*/ Schema.Struct({ + ok: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Liveness check. + * + * Public endpoint. Returns `{ ok: true }` when the auth handler is reachable. + */ +export const ok = /*@__PURE__*/ API.make(() => ({ + inputSchema: OkInput, + outputSchema: OkOutput, +})); diff --git a/packages/better-auth/src/operations/requestPasswordReset.ts b/packages/better-auth/src/operations/requestPasswordReset.ts new file mode 100644 index 0000000000..2562e90fcc --- /dev/null +++ b/packages/better-auth/src/operations/requestPasswordReset.ts @@ -0,0 +1,42 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export interface RequestPasswordResetInput { + email: string; + redirectTo?: string; +} +export const RequestPasswordResetInput = /*@__PURE__*/ Schema.Struct({ + email: Schema.String, + redirectTo: Schema.optional(Schema.String), +}).pipe( + T.Http({ method: "POST", path: "/request-password-reset" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface RequestPasswordResetOutput { + status: boolean; + message?: string; +} +export const RequestPasswordResetOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, + message: Schema.optional(Schema.String), +}) as unknown as Schema.Codec; + +/** + * Request a password-reset email. + * + * Public endpoint. Always returns a success shape (to avoid leaking whether + * an account exists). On newer better-auth this is `/request-password-reset`; + * older servers exposed it as `/forget-password`. + * + * @param email - The account email to send the reset link to. + * @param redirectTo - Optional URL to land on after the emailed link is opened. + */ +export const requestPasswordReset = /*@__PURE__*/ API.make(() => ({ + inputSchema: RequestPasswordResetInput, + outputSchema: RequestPasswordResetOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/better-auth/src/operations/resetPassword.ts b/packages/better-auth/src/operations/resetPassword.ts new file mode 100644 index 0000000000..c80a3f1ba4 --- /dev/null +++ b/packages/better-auth/src/operations/resetPassword.ts @@ -0,0 +1,39 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export interface ResetPasswordInput { + newPassword: string; + token?: string; +} +export const ResetPasswordInput = /*@__PURE__*/ Schema.Struct({ + newPassword: Schema.String, + token: Schema.optional(Schema.String).pipe(T.QueryParam()), +}).pipe( + T.Http({ method: "POST", path: "/reset-password" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface ResetPasswordOutput { + status: boolean; +} +export const ResetPasswordOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Reset a password using the token from a reset email. + * + * Public endpoint (the reset token authorizes it). The token is passed as the + * `token` query param; the new password goes in the body. + * + * @param newPassword - The new password to set. + * @param token - The reset token delivered by the reset-email link. + */ +export const resetPassword = /*@__PURE__*/ API.make(() => ({ + inputSchema: ResetPasswordInput, + outputSchema: ResetPasswordOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/better-auth/src/operations/revokeOtherSessions.ts b/packages/better-auth/src/operations/revokeOtherSessions.ts new file mode 100644 index 0000000000..9b1eac0295 --- /dev/null +++ b/packages/better-auth/src/operations/revokeOtherSessions.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { Unauthorized } from "../errors.ts"; + +// Input Schema +export interface RevokeOtherSessionsInput {} +export const RevokeOtherSessionsInput = /*@__PURE__*/ Schema.Struct({}).pipe( + T.Http({ method: "POST", path: "/revoke-other-sessions" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface RevokeOtherSessionsOutput { + status: boolean; +} +export const RevokeOtherSessionsOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Revoke every session except the current one. + * + * Requires a fresh authenticated session. + */ +export const revokeOtherSessions = /*@__PURE__*/ API.make(() => ({ + inputSchema: RevokeOtherSessionsInput, + outputSchema: RevokeOtherSessionsOutput, + errors: [Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/revokeSession.ts b/packages/better-auth/src/operations/revokeSession.ts new file mode 100644 index 0000000000..28ae85643d --- /dev/null +++ b/packages/better-auth/src/operations/revokeSession.ts @@ -0,0 +1,35 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Unauthorized } from "../errors.ts"; + +// Input Schema +export interface RevokeSessionInput { + token: string; +} +export const RevokeSessionInput = /*@__PURE__*/ Schema.Struct({ + token: Schema.String, +}).pipe( + T.Http({ method: "POST", path: "/revoke-session" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface RevokeSessionOutput { + status: boolean; +} +export const RevokeSessionOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Revoke a specific session by its token. + * + * Requires a fresh authenticated session. + * + * @param token - The session token to revoke. + */ +export const revokeSession = /*@__PURE__*/ API.make(() => ({ + inputSchema: RevokeSessionInput, + outputSchema: RevokeSessionOutput, + errors: [BadRequest, Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/revokeSessions.ts b/packages/better-auth/src/operations/revokeSessions.ts new file mode 100644 index 0000000000..759f93431d --- /dev/null +++ b/packages/better-auth/src/operations/revokeSessions.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { Unauthorized } from "../errors.ts"; + +// Input Schema +export interface RevokeSessionsInput {} +export const RevokeSessionsInput = /*@__PURE__*/ Schema.Struct({}).pipe( + T.Http({ method: "POST", path: "/revoke-sessions" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface RevokeSessionsOutput { + status: boolean; +} +export const RevokeSessionsOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Revoke all of the current user's sessions (including the current one). + * + * Requires a fresh authenticated session. + */ +export const revokeSessions = /*@__PURE__*/ API.make(() => ({ + inputSchema: RevokeSessionsInput, + outputSchema: RevokeSessionsOutput, + errors: [Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/sendVerificationEmail.ts b/packages/better-auth/src/operations/sendVerificationEmail.ts new file mode 100644 index 0000000000..d5691b230b --- /dev/null +++ b/packages/better-auth/src/operations/sendVerificationEmail.ts @@ -0,0 +1,38 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; + +// Input Schema +export interface SendVerificationEmailInput { + email: string; + callbackURL?: string; +} +export const SendVerificationEmailInput = /*@__PURE__*/ Schema.Struct({ + email: Schema.String, + callbackURL: Schema.optional(Schema.String), +}).pipe( + T.Http({ method: "POST", path: "/send-verification-email" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface SendVerificationEmailOutput { + status: boolean; +} +export const SendVerificationEmailOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Send (or resend) a verification email. + * + * Public endpoint; runs in constant time to avoid leaking account existence. + * + * @param email - The address to send the verification email to. + * @param callbackURL - Optional redirect URL embedded in the verification link. + */ +export const sendVerificationEmail = /*@__PURE__*/ API.make(() => ({ + inputSchema: SendVerificationEmailInput, + outputSchema: SendVerificationEmailOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/better-auth/src/operations/signInEmail.ts b/packages/better-auth/src/operations/signInEmail.ts new file mode 100644 index 0000000000..65abbd5c38 --- /dev/null +++ b/packages/better-auth/src/operations/signInEmail.ts @@ -0,0 +1,54 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Forbidden, Unauthorized } from "../errors.ts"; +import { User } from "../schemas.ts"; + +// Input Schema +export interface SignInEmailInput { + email: string; + password: string; + rememberMe?: boolean; + callbackURL?: string; +} +export const SignInEmailInput = /*@__PURE__*/ Schema.Struct({ + email: Schema.String, + password: Schema.String, + rememberMe: Schema.optional(Schema.Boolean), + callbackURL: Schema.optional(Schema.String), +}).pipe( + T.Http({ method: "POST", path: "/sign-in/email" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface SignInEmailOutput { + redirect: boolean; + token: string; + url?: string; + user: User; +} +export const SignInEmailOutput = /*@__PURE__*/ Schema.Struct({ + redirect: Schema.Boolean, + token: Schema.String, + url: Schema.optional(Schema.String), + user: User, +}) as unknown as Schema.Codec; + +/** + * Sign in with email and password. + * + * On success the response carries the session `token` (send it as + * `Authorization: Bearer ` on subsequent authenticated calls when the + * `bearer` plugin is enabled). Fails with `Unauthorized` on bad credentials + * and `Forbidden` when email verification is required but incomplete. + * + * @param email - The user's email address. + * @param password - The user's password. + * @param rememberMe - Optional (default true); persist the session. + * @param callbackURL - Optional redirect URL. + */ +export const signInEmail = /*@__PURE__*/ API.make(() => ({ + inputSchema: SignInEmailInput, + outputSchema: SignInEmailOutput, + errors: [BadRequest, Unauthorized, Forbidden] as const, +})); diff --git a/packages/better-auth/src/operations/signInSocial.ts b/packages/better-auth/src/operations/signInSocial.ts new file mode 100644 index 0000000000..debb04c23b --- /dev/null +++ b/packages/better-auth/src/operations/signInSocial.ts @@ -0,0 +1,76 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; +import { User } from "../schemas.ts"; + +// Input Schema +export interface SignInSocialInput { + provider: string; + callbackURL?: string; + newUserCallbackURL?: string; + errorCallbackURL?: string; + disableRedirect?: boolean; + scopes?: string[]; + requestSignUp?: boolean; + loginHint?: string; + idToken?: { + token: string; + nonce?: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + }; +} +export const SignInSocialInput = /*@__PURE__*/ Schema.Struct({ + provider: Schema.String, + callbackURL: Schema.optional(Schema.String), + newUserCallbackURL: Schema.optional(Schema.String), + errorCallbackURL: Schema.optional(Schema.String), + disableRedirect: Schema.optional(Schema.Boolean), + scopes: Schema.optional(Schema.Array(Schema.String)), + requestSignUp: Schema.optional(Schema.Boolean), + loginHint: Schema.optional(Schema.String), + idToken: Schema.optional( + Schema.Struct({ + token: Schema.String, + nonce: Schema.optional(Schema.String), + accessToken: Schema.optional(Schema.String), + refreshToken: Schema.optional(Schema.String), + expiresAt: Schema.optional(Schema.Number), + }), + ), +}).pipe( + T.Http({ method: "POST", path: "/sign-in/social" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface SignInSocialOutput { + redirect: boolean; + url?: string; + token?: string; + user?: User; +} +export const SignInSocialOutput = /*@__PURE__*/ Schema.Struct({ + redirect: Schema.Boolean, + url: Schema.optional(Schema.String), + token: Schema.optional(Schema.String), + user: Schema.optional(User), +}) as unknown as Schema.Codec; + +/** + * Sign in with a social / OAuth provider. + * + * For the redirect (browser) flow the response is `{ url, redirect: true }` — + * navigate the user to `url`. For the native `idToken` flow the response is + * `{ redirect: false, token, user }`. + * + * @param provider - A configured social provider id (e.g. "github", "google"). + * @param callbackURL - Optional post-auth redirect URL. + * @param idToken - Optional; native ID-token flow (skips the browser redirect). + */ +export const signInSocial = /*@__PURE__*/ API.make(() => ({ + inputSchema: SignInSocialInput, + outputSchema: SignInSocialOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/better-auth/src/operations/signOut.ts b/packages/better-auth/src/operations/signOut.ts new file mode 100644 index 0000000000..570fde5d7b --- /dev/null +++ b/packages/better-auth/src/operations/signOut.ts @@ -0,0 +1,30 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { Unauthorized } from "../errors.ts"; + +// Input Schema +export interface SignOutInput {} +export const SignOutInput = /*@__PURE__*/ Schema.Struct({}).pipe( + T.Http({ method: "POST", path: "/sign-out" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface SignOutOutput { + success: boolean; +} +export const SignOutOutput = /*@__PURE__*/ Schema.Struct({ + success: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Sign out the current session. + * + * Requires an authenticated session (send the session token as a bearer). + * Clears the session server-side and returns `{ success: true }`. + */ +export const signOut = /*@__PURE__*/ API.make(() => ({ + inputSchema: SignOutInput, + outputSchema: SignOutOutput, + errors: [Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/signUpEmail.ts b/packages/better-auth/src/operations/signUpEmail.ts new file mode 100644 index 0000000000..5bd4c6f056 --- /dev/null +++ b/packages/better-auth/src/operations/signUpEmail.ts @@ -0,0 +1,54 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Conflict, UnprocessableEntity } from "../errors.ts"; +import { User } from "../schemas.ts"; + +// Input Schema +export interface SignUpEmailInput { + name: string; + email: string; + password: string; + image?: string; + callbackURL?: string; + rememberMe?: boolean; +} +export const SignUpEmailInput = /*@__PURE__*/ Schema.Struct({ + name: Schema.String, + email: Schema.String, + password: Schema.String, + image: Schema.optional(Schema.String), + callbackURL: Schema.optional(Schema.String), + rememberMe: Schema.optional(Schema.Boolean), +}).pipe( + T.Http({ method: "POST", path: "/sign-up/email" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface SignUpEmailOutput { + token: string | null; + user: User; +} +export const SignUpEmailOutput = /*@__PURE__*/ Schema.Struct({ + token: Schema.NullOr(Schema.String), + user: User, +}) as unknown as Schema.Codec; + +/** + * Sign up with email and password. + * + * Creates a new user. With auto sign-in on (the default) the response carries + * a session `token`; if auto sign-in is disabled, `token` is `null`. + * + * @param name - The user's display name. + * @param email - The user's email address. + * @param password - The user's password (subject to the server's policy). + * @param image - Optional avatar URL. + * @param callbackURL - Optional post-verification redirect URL. + * @param rememberMe - Optional; persist the session beyond the browser session. + */ +export const signUpEmail = /*@__PURE__*/ API.make(() => ({ + inputSchema: SignUpEmailInput, + outputSchema: SignUpEmailOutput, + errors: [BadRequest, UnprocessableEntity, Conflict] as const, +})); diff --git a/packages/better-auth/src/operations/unlinkAccount.ts b/packages/better-auth/src/operations/unlinkAccount.ts new file mode 100644 index 0000000000..e3e3e9626b --- /dev/null +++ b/packages/better-auth/src/operations/unlinkAccount.ts @@ -0,0 +1,39 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Unauthorized } from "../errors.ts"; + +// Input Schema +export interface UnlinkAccountInput { + providerId: string; + accountId?: string; +} +export const UnlinkAccountInput = /*@__PURE__*/ Schema.Struct({ + providerId: Schema.String, + accountId: Schema.optional(Schema.String), +}).pipe( + T.Http({ method: "POST", path: "/unlink-account" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface UnlinkAccountOutput { + status: boolean; +} +export const UnlinkAccountOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Unlink a linked account from the current user. + * + * Requires a fresh authenticated session. The server refuses to unlink the + * last remaining credential if doing so would lock the user out. + * + * @param providerId - The provider id of the account to unlink. + * @param accountId - Optional; disambiguates when several accounts share a provider. + */ +export const unlinkAccount = /*@__PURE__*/ API.make(() => ({ + inputSchema: UnlinkAccountInput, + outputSchema: UnlinkAccountOutput, + errors: [BadRequest, Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/updateUser.ts b/packages/better-auth/src/operations/updateUser.ts new file mode 100644 index 0000000000..0d2a48a7c0 --- /dev/null +++ b/packages/better-auth/src/operations/updateUser.ts @@ -0,0 +1,39 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Unauthorized } from "../errors.ts"; + +// Input Schema +export interface UpdateUserInput { + name?: string; + image?: string | null; +} +export const UpdateUserInput = /*@__PURE__*/ Schema.Struct({ + name: Schema.optional(Schema.String), + image: Schema.optional(Schema.NullOr(Schema.String)), +}).pipe( + T.Http({ method: "POST", path: "/update-user" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface UpdateUserOutput { + status: boolean; +} +export const UpdateUserOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Update the current user's profile fields. + * + * Requires an authenticated session. Email is not changeable here — use + * `changeEmail`. Additional configured user fields may also be passed. + * + * @param name - Optional new display name. + * @param image - Optional new avatar URL (or `null` to clear). + */ +export const updateUser = /*@__PURE__*/ API.make(() => ({ + inputSchema: UpdateUserInput, + outputSchema: UpdateUserOutput, + errors: [BadRequest, Unauthorized] as const, +})); diff --git a/packages/better-auth/src/operations/verifyEmail.ts b/packages/better-auth/src/operations/verifyEmail.ts new file mode 100644 index 0000000000..f8d380126c --- /dev/null +++ b/packages/better-auth/src/operations/verifyEmail.ts @@ -0,0 +1,42 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest } from "../errors.ts"; +import { User } from "../schemas.ts"; + +// Input Schema +export interface VerifyEmailInput { + token: string; + callbackURL?: string; +} +export const VerifyEmailInput = /*@__PURE__*/ Schema.Struct({ + token: Schema.String.pipe(T.QueryParam()), + callbackURL: Schema.optional(Schema.String).pipe(T.QueryParam()), +}).pipe( + T.Http({ method: "GET", path: "/verify-email" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface VerifyEmailOutput { + status?: boolean; + user?: User | null; +} +export const VerifyEmailOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.optional(Schema.Boolean), + user: Schema.optional(Schema.NullOr(User)), +}) as unknown as Schema.Codec; + +/** + * Verify an email address with the token from a verification email. + * + * Public endpoint (the JWT token authorizes it). When `callbackURL` is set the + * server redirects there after verifying; otherwise it returns a JSON status. + * + * @param token - The verification token from the email link. + * @param callbackURL - Optional redirect URL after verification. + */ +export const verifyEmail = /*@__PURE__*/ API.make(() => ({ + inputSchema: VerifyEmailInput, + outputSchema: VerifyEmailOutput, + errors: [BadRequest] as const, +})); diff --git a/packages/better-auth/src/operations/verifyPassword.ts b/packages/better-auth/src/operations/verifyPassword.ts new file mode 100644 index 0000000000..2f975f6989 --- /dev/null +++ b/packages/better-auth/src/operations/verifyPassword.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema"; +import { API } from "../client.ts"; +import * as T from "../traits.ts"; +import { BadRequest, Unauthorized } from "../errors.ts"; + +// Input Schema +export interface VerifyPasswordInput { + password: string; +} +export const VerifyPasswordInput = /*@__PURE__*/ Schema.Struct({ + password: Schema.String, +}).pipe( + T.Http({ method: "POST", path: "/verify-password" }), +) as unknown as Schema.Codec; + +// Output Schema +export interface VerifyPasswordOutput { + status: boolean; +} +export const VerifyPasswordOutput = /*@__PURE__*/ Schema.Struct({ + status: Schema.Boolean, +}) as unknown as Schema.Codec; + +/** + * Verify the current user's password (a re-authentication gate). + * + * Requires an authenticated session. Returns `{ status: true }` when the + * supplied password matches. + * + * @param password - The password to verify against the current user. + */ +export const verifyPassword = /*@__PURE__*/ API.make(() => ({ + inputSchema: VerifyPasswordInput, + outputSchema: VerifyPasswordOutput, + errors: [BadRequest, Unauthorized] as const, +})); diff --git a/packages/better-auth/src/retry.ts b/packages/better-auth/src/retry.ts new file mode 100644 index 0000000000..f67e2fe302 --- /dev/null +++ b/packages/better-auth/src/retry.ts @@ -0,0 +1,57 @@ +/** + * better-auth retry configuration. + * + * Defines the per-SDK `Retry` Context.Service tag that + * `packages/better-auth/src/client.ts` wires into `makeAPI`. Callers can + * install a blanket retry policy at the layer level and have every + * better-auth API call below it pick it up: + * + * @example + * ```ts + * import * as BetterAuth from "@distilled.cloud/better-auth"; + * + * myEffect.pipe(BetterAuth.Retry.transient); + * Effect.provide(myEffect, Layer.succeed(BetterAuth.Retry.Retry, customPolicy)); + * ``` + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + type Policy, + throttlingFactory, + transientFactory, +} from "@distilled.cloud/core/retry"; + +export { + type Options, + type Factory, + type Policy, + makeDefault, + jittered, + capped, + throttlingOptions, + transientOptions, + throttlingFactory, + transientFactory, +} from "@distilled.cloud/core/retry"; + +/** Context tag for configuring retry behavior of better-auth API calls. */ +export class Retry extends Context.Service()( + "BetterAuthRetry", +) {} + +/** Provides a custom retry policy to every better-auth API call below it. */ +export const policy = (optionsOrFactory: Policy) => + Effect.provide(Layer.succeed(Retry, optionsOrFactory)); + +/** Disables all automatic retries. */ +export const none = Effect.provide( + Layer.succeed(Retry, { while: () => false }), +); + +/** Apply the throttling retry policy (retries throttling errors indefinitely). */ +export const throttling = policy(throttlingFactory); + +/** Apply the transient retry policy (retries all transient errors indefinitely). */ +export const transient = policy(transientFactory); diff --git a/packages/better-auth/src/schemas.ts b/packages/better-auth/src/schemas.ts new file mode 100644 index 0000000000..7c6461b24b --- /dev/null +++ b/packages/better-auth/src/schemas.ts @@ -0,0 +1,69 @@ +/** + * Shared response object schemas reused across better-auth operations. + * + * Dates are serialized as ISO-8601 strings over the HTTP JSON body, so they + * are modeled as `string` here (not `Schema.Date`). better-auth allows + * `additionalFields` on `user`/`session`; excess properties are ignored on + * decode rather than surfaced as typed fields. + */ +import * as Schema from "effect/Schema"; + +/** An authenticated user record. */ +export interface User { + id: string; + name: string; + email: string; + emailVerified: boolean; + image?: string | null; + createdAt: string; + updatedAt: string; +} +export const User = /*@__PURE__*/ Schema.Struct({ + id: Schema.String, + name: Schema.String, + email: Schema.String, + emailVerified: Schema.Boolean, + image: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +/** A session record for a signed-in user. */ +export interface Session { + id: string; + token: string; + userId: string; + expiresAt: string; + ipAddress?: string | null; + userAgent?: string | null; + createdAt: string; + updatedAt: string; +} +export const Session = /*@__PURE__*/ Schema.Struct({ + id: Schema.String, + token: Schema.String, + userId: Schema.String, + expiresAt: Schema.String, + ipAddress: Schema.optional(Schema.NullOr(Schema.String)), + userAgent: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +/** A linked OAuth/credential account, as returned by `listAccounts`. */ +export interface Account { + id: string; + providerId: string; + accountId: string; + scopes: string[]; + createdAt: string; + updatedAt: string; +} +export const Account = /*@__PURE__*/ Schema.Struct({ + id: Schema.String, + providerId: Schema.String, + accountId: Schema.String, + scopes: Schema.Array(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, +}); diff --git a/packages/better-auth/src/sensitive.ts b/packages/better-auth/src/sensitive.ts new file mode 100644 index 0000000000..2167a39b2a --- /dev/null +++ b/packages/better-auth/src/sensitive.ts @@ -0,0 +1,4 @@ +/** + * Re-export sensitive data schemas from sdk-core. + */ +export * from "@distilled.cloud/core/sensitive"; diff --git a/packages/better-auth/src/traits.ts b/packages/better-auth/src/traits.ts new file mode 100644 index 0000000000..cf13e396a9 --- /dev/null +++ b/packages/better-auth/src/traits.ts @@ -0,0 +1,4 @@ +/** + * Re-export the shared traits system from sdk-core. + */ +export * from "@distilled.cloud/core/traits"; diff --git a/packages/better-auth/tests/email-password.test.ts b/packages/better-auth/tests/email-password.test.ts new file mode 100644 index 0000000000..3be15bd7d8 --- /dev/null +++ b/packages/better-auth/tests/email-password.test.ts @@ -0,0 +1,128 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Effect } from "effect"; +import { Unauthorized } from "../src/errors.ts"; +import { changePassword } from "../src/operations/changePassword.ts"; +import { getSession } from "../src/operations/getSession.ts"; +import { listSessions } from "../src/operations/listSessions.ts"; +import { ok } from "../src/operations/ok.ts"; +import { signInEmail } from "../src/operations/signInEmail.ts"; +import { signOut } from "../src/operations/signOut.ts"; +import { signUpEmail } from "../src/operations/signUpEmail.ts"; +import { updateUser } from "../src/operations/updateUser.ts"; +import { + type AuthServer, + runEffect, + startAuthServer, + testRunId, +} from "./setup.ts"; + +describe("better-auth email/password", () => { + let server: AuthServer; + let baseUrl: string; + + const email = `distilled-ba-${testRunId}@example.com`; + const password = "correct-horse-battery-staple"; + const newPassword = "even-better-horse-battery"; + + // Session token captured from sign-up, used to authenticate later calls. + let token: string; + + beforeAll(async () => { + server = await startAuthServer(); + baseUrl = server.baseUrl; + }); + + afterAll(async () => { + await server?.close(); + }); + + describe("ok", () => { + it("reports the handler is reachable", async () => { + const result = await runEffect(baseUrl, ok({})); + expect(result.ok).toBe(true); + }); + }); + + describe("signUpEmail", () => { + it("creates a user and returns a session token", async () => { + const result = await runEffect( + baseUrl, + signUpEmail({ name: "Ada Lovelace", email, password }), + ); + expect(result.user.email).toBe(email); + expect(result.user.name).toBe("Ada Lovelace"); + expect(typeof result.token).toBe("string"); + token = result.token as string; + }); + }); + + describe("getSession", () => { + it("returns the session for an authenticated request", async () => { + const result = await runEffect(baseUrl, getSession({}), token); + expect(result).not.toBeNull(); + expect(result?.user.email).toBe(email); + expect(result?.session.token).toBeDefined(); + }); + + it("returns null for an unauthenticated request", async () => { + const result = await runEffect(baseUrl, getSession({})); + expect(result).toBeNull(); + }); + }); + + describe("updateUser", () => { + it("updates the profile and is reflected in the session", async () => { + const updated = await runEffect( + baseUrl, + updateUser({ name: "Augusta Ada King" }), + token, + ); + expect(updated.status).toBe(true); + + const session = await runEffect(baseUrl, getSession({}), token); + expect(session?.user.name).toBe("Augusta Ada King"); + }); + }); + + describe("listSessions", () => { + it("lists the current user's active sessions", async () => { + const sessions = await runEffect(baseUrl, listSessions({}), token); + expect(Array.isArray(sessions)).toBe(true); + expect(sessions.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("changePassword", () => { + it("changes the password and the new one signs in", async () => { + await runEffect( + baseUrl, + changePassword({ currentPassword: password, newPassword }), + token, + ); + + const signedIn = await runEffect( + baseUrl, + signInEmail({ email, password: newPassword }), + ); + expect(signedIn.user.email).toBe(email); + expect(typeof signedIn.token).toBe("string"); + }); + }); + + describe("signInEmail (errors)", () => { + it("fails with Unauthorized on a wrong password", async () => { + const error = await runEffect( + baseUrl, + signInEmail({ email, password: "totally-wrong" }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(Unauthorized); + }); + }); + + describe("signOut", () => { + it("signs out the current session", async () => { + const result = await runEffect(baseUrl, signOut({}), token); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/better-auth/tests/setup.ts b/packages/better-auth/tests/setup.ts new file mode 100644 index 0000000000..7c4c7fbd91 --- /dev/null +++ b/packages/better-auth/tests/setup.ts @@ -0,0 +1,117 @@ +/** + * Test harness for the better-auth SDK. + * + * Unlike the hosted-cloud SDKs, better-auth is self-hosted, so these tests + * boot a REAL better-auth server in-process (memory adapter + bearer plugin) + * on an ephemeral port and point the SDK at it. No external credentials or + * network access required — the suite is fully hermetic. + */ +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { toNodeHandler } from "better-auth/node"; +import { bearer } from "better-auth/plugins/bearer"; +import { Effect, Layer } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as http from "node:http"; +import { type Config, Credentials, layer } from "../src/credentials.ts"; + +/** + * Short random hex string generated once per test run, mixed into resource + * names (emails) so parallel runs don't collide. + */ +export const testRunId: string = crypto + .randomUUID() + .replace(/-/g, "") + .slice(0, 8); + +/** A running in-process better-auth server. */ +export interface AuthServer { + /** SDK base URL (origin + `/api/auth` mount path). */ + readonly baseUrl: string; + /** Shut the server down. */ + readonly close: () => Promise; +} + +/** + * Start a self-contained better-auth server on an OS-assigned free port. + * + * The port is resolved BEFORE constructing `betterAuth` (its `baseURL` needs + * the port) by listening first with a deferred request handler, then wiring + * the handler once the auth instance exists — no port guessing, no race. + */ +export const startAuthServer = async (): Promise => { + let handler: http.RequestListener | undefined; + const server = http.createServer((req, res) => { + if (handler === undefined) { + res.statusCode = 503; + res.end(); + return; + } + handler(req, res); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("failed to bind test server to a TCP port"); + } + const origin = `http://127.0.0.1:${address.port}`; + + const auth = betterAuth({ + baseURL: origin, + basePath: "/api/auth", + secret: `test-secret-${testRunId}-0123456789abcdef`, + // The memory adapter only lazily creates a table on the first write, but + // sign-up reads (`findUserByEmail`) before it writes — seed the default + // better-auth models up front so those reads don't hit a missing table. + database: memoryAdapter({ + user: [], + session: [], + account: [], + verification: [], + }), + emailAndPassword: { enabled: true }, + plugins: [bearer()], + // No browser Origin header is sent by node fetch; disable the CSRF origin + // gate so state-changing POSTs from the SDK are accepted in tests. + advanced: { disableCSRFCheck: true }, + }); + handler = toNodeHandler(auth); + + return { + baseUrl: `${origin}/api/auth`, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ), + }; +}; + +/** + * Build the Effect layer (credentials + fetch HTTP client) for a given base + * URL and optional session token. + */ +export const testLayer = ( + baseUrl: string, + token?: string, +): Layer.Layer => + Layer.merge(layer({ baseUrl, token }), FetchHttpClient.layer); + +/** + * Run an Effect against the test server. Pass a `token` to authenticate the + * call as a signed-in user (sent as `Authorization: Bearer `). + */ +export const runEffect = ( + baseUrl: string, + effect: Effect.Effect, + token?: string, +): Promise => + Effect.runPromise( + effect.pipe(Effect.provide(testLayer(baseUrl, token))) as Effect.Effect< + A, + E, + never + >, + ); + +export type { Config }; diff --git a/packages/better-auth/tsconfig.json b/packages/better-auth/tsconfig.json new file mode 100644 index 0000000000..59f710c649 --- /dev/null +++ b/packages/better-auth/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src" + }, + "references": [ + { + "path": "../core" + } + ] +} diff --git a/packages/better-auth/vitest.config.ts b/packages/better-auth/vitest.config.ts new file mode 100644 index 0000000000..94092b93ba --- /dev/null +++ b/packages/better-auth/vitest.config.ts @@ -0,0 +1,12 @@ +import { config } from "dotenv"; +import { resolve } from "path"; + +config({ path: resolve(__dirname, "../../.env") }); +config({ path: resolve(__dirname, ".env") }); + +export default { + test: { + include: ["tests/**/*.test.ts"], + testTimeout: 30000, + }, +}; diff --git a/tsconfig.json b/tsconfig.json index d2c3778f3e..5313532906 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./packages/aws/tsconfig.json" }, { "path": "./packages/axiom/tsconfig.json" }, { "path": "./packages/azure/tsconfig.json" }, + { "path": "./packages/better-auth/tsconfig.json" }, { "path": "./packages/cloudflare/tsconfig.json" }, { "path": "./packages/coinbase/tsconfig.json" }, { "path": "./packages/expo-eas/tsconfig.json" },