Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -110,6 +111,9 @@ jobs:
expo-eas:
- 'packages/expo-eas/**'
- 'packages/core/**'
better-auth:
- 'packages/better-auth/**'
- 'packages/core/**'

ci-core:
needs: detect-changes
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
97 changes: 77 additions & 20 deletions bun.lock

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions packages/better-auth/README.md
Original file line number Diff line number Diff line change
@@ -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 <token>`, 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
91 changes: 91 additions & 0 deletions packages/better-auth/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
4 changes: 4 additions & 0 deletions packages/better-auth/src/category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Re-export the shared category system from sdk-core.
*/
export * from "@distilled.cloud/core/category";
87 changes: 87 additions & 0 deletions packages/better-auth/src/client.ts
Original file line number Diff line number Diff line change
@@ -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 <token>` (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<string, string | undefined>,
): Effect.Effect<never, unknown> => {
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: Credentials as any,
getBaseUrl: (creds: any) => creds.baseUrl,
getAuthHeaders: (creds: any): Record<string, string> =>
creds.token === undefined
? {}
: { Authorization: `Bearer ${Redacted.value(creds.token)}` },
matchError,
ParseError: BetterAuthParseError as any,
retry: Retry as any,
});
Loading