first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
# @suaveplan/api-auth-better-auth — Better-Auth adapter for the @suaveplan/api foundation. Ships two independently-wireable factories implementing the auth.session and auth.provider capability contracts against a Better-Auth instance backed by its own Postgres connection. Maps Better-Auth user+session onto the @suaveplan/api Principal contract, mounts canonical PROVIDER_ROUTE_SURFACE routes (translating Better-Auth's /sign-up etc. idiom), exposes a GitHub OAuth flow via Better-Auth's genericOAuth plugin (so the mock OAuth IdP can be wired end-to-end), guards route-prefix collisions against @suaveplan/api-host's reserved-prefix contract, and emits OTel sessions/oauth/mapping instruments. The ./testing subpath re-exports both conformance suites bound to Postgres-backed factories.
Stage-6 Better-Auth adapter for `@suaveplan/api`. Ships two
**independently-wireable** capability surfaces — `createBetterAuthSession`
(implements `auth.session` from `@suaveplan/api-auth`) and
`createBetterAuthProvider` (implements `auth.provider` from
`@suaveplan/api-auth-provider`). Operators MAY wire either side in
isolation OR both together; sharing one options bag between the two
factories shares the underlying Better-Auth instance + DB pool.
> **Two capability surfaces, wire independently.** The session adapter
> reads cookies + bearer tokens; the provider adapter mounts the canonical
> `PROVIDER_ROUTE_SURFACE` (9 routes) under a configurable prefix. They
> share no required code path — wiring only one side does NOT install the
> other side's surface.
<!-- begin:auto-docs -->
## Documentation tree
- [db](./src/db/database.md) — Lazy holder for the underlying Better-Auth instance plus a helper that projects {@link BetterAuthOptions} onto the loos…
- [database](./src/db/database.md) — Lazy holder for the underlying Better-Auth instance plus a helper that projects {@link BetterAuthOptions} onto the loos…
- [error-codes](./src/error-codes/error-codes.md) — Stable, tracked error-code registry for `@suaveplan/api-auth-better-auth`. Every fault path inside the adapter…
- [error-codes](./src/error-codes/error-codes.md) — Stable, tracked error-code registry for `@suaveplan/api-auth-better-auth`. Every fault path inside the adapter…
- [independence](./src/independence/session-without-provider.md) — Pin the design invariant from the Stage-6 spec change: the Better-Auth adapter ships two factories — `createBetterA…
- [session-without-provider](./src/independence/session-without-provider.md) — Pin the design invariant from the Stage-6 spec change: the Better-Auth adapter ships two factories — `createBetterA…
- [instruments](./src/instruments/instruments.md) — Lazily-bound OpenTelemetry instrument bundle for the Better-Auth adapter. Built once per process against the current gl…
- [instruments](./src/instruments/instruments.md) — Lazily-bound OpenTelemetry instrument bundle for the Better-Auth adapter. Built once per process against the current gl…
- [default-factory](./src/oauth/default-factory.md) — Default `betterAuthFactory` used when an operator does not supply one via `BetterAuthOptions.betterAuthFactory`. Dynami…
- [github](./src/oauth/github.md) — Project Suaveplan's {@link BetterAuthOauthProvider} entries onto the `genericOAuth` plugin config Better-Auth consumes.…
- [options](./src/options/options.md) — Zod-validated boundary for the Better-Auth adapter's configuration bag. Both `createBetterAuthSession` and `createBette…
- [options](./src/options/options.md) — Zod-validated boundary for the Better-Auth adapter's configuration bag. Both `createBetterAuthSession` and `createBette…
- [principal](./src/principal/map-user-to-principal.md) — Default Better-Auth `(user, session)` → `Principal` mapping consumed by both `createBetterAuthSession` and `createBette…
- [map-user-to-principal](./src/principal/map-user-to-principal.md) — Default Better-Auth `(user, session)` → `Principal` mapping consumed by both `createBetterAuthSession` and `createBette…
- [create-provider](./src/provider/create-provider.md) — Factory for the `auth.provider` half of the Stage-6 split. Returns an `AuthProviderAdapter` (per `@suaveplan/api-auth-p…
- [route-translation](./src/provider/route-translation.md) — Translation table mapping every entry in `PROVIDER_ROUTE_SURFACE` (the canonical 9-route foundation surface) to the mat…
- [route-prefix](./src/route-prefix/route-prefix.md) — Resolve an operator-supplied `routePrefix` against the host's reserved-prefix contract from `@suaveplan/api-host/reserv…
- [route-prefix](./src/route-prefix/route-prefix.md) — Resolve an operator-supplied `routePrefix` against the host's reserved-prefix contract from `@suaveplan/api-host/reserv…
- [session](./src/session/create-session.md) — Factory for the `auth.session` half of the Stage-6 split. Returns a `SessionResolver` that asks Better-Auth's `auth.api…
- [create-session](./src/session/create-session.md) — Factory for the `auth.session` half of the Stage-6 split. Returns a `SessionResolver` that asks Better-Auth's `auth.api…
- [testing](./src/testing/testing.md) — `@suaveplan/api-auth-better-auth/testing` is the subpath downstream packages import when they want to assert their own…
- [testing](./src/testing/testing.md) — `@suaveplan/api-auth-better-auth/testing` is the subpath downstream packages import when they want to assert their own…
<!-- end:auto-docs -->
## Modules
- **`error-codes/`** — 10-code `AUTH_BA.*` registry + `BetterAuthError`.
- **`options/`** — Zod-validated `BetterAuthOptions` boundary + canonical
defaults.
- **`db/`** — `LazyBetterAuth` (lazy + cached + concurrency-safe) +
`buildBetterAuthFactoryArgs` (outer-options → Better-Auth-args
projection).
- **`principal/`** — default Better-Auth `(user, session)` → `Principal`
mapping; replaceable via `BetterAuthOptions.principalMapping`.
- **`session/`** — `createBetterAuthSession(opts)` factory implementing
the `SessionResolver` contract.
- **`provider/`** — `createBetterAuthProvider(opts)` factory implementing
the `AuthProviderAdapter` contract; mounts canonical routes that rewrite
onto Better-Auth's native sub-paths.
- **`route-prefix/`** — `assertRoutePrefixAvailable` collision check
against `@suaveplan/api-host/reserved-prefix`.
- **`oauth/`** — `genericOAuth` plugin config builder + default
`betterAuth(...)` factory that dynamically loads the plugin when OAuth
providers are configured. The GitHub OAuth flow is the canonical
reference; the implementation works against the real GitHub IdP in
production and the mock IdP from
`@suaveplan/testing-integration-oauth-mock` in tests because we use
`genericOAuth` (which accepts the full URL triple) rather than the
hard-coded `github` social-provider.
- **`instruments/`** — OTel counters + histograms per the spec's metrics
contract.
- **`independence/`** — invariant pin: session-only / provider-only wiring
exposes nothing from the other side.
- **`testing/`** — `./testing` subpath re-exporting both conformance
harnesses bound to Better-Auth factories.
## Usage
```ts
import { Pool } from "pg";
import {
createBetterAuthProvider,
createBetterAuthSession,
} from "@suaveplan/api-auth-better-auth";
const pool = new Pool({ connectionString: process.env.BETTER_AUTH_DB_URL! });
const opts = {
database: pool,
baseUrl: "https://api.example.com",
secret: process.env.BETTER_AUTH_SECRET!,
routePrefix: "/auth",
oauth: {
providers: {
github: {
providerId: "github",
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
},
},
},
};
// Wire both sides — they share one Better-Auth instance via per-options WeakMap:
host.registerAuth({
resolver: createBetterAuthSession(opts),
csrf: true,
});
host.registerAuthProvider(createBetterAuthProvider(opts));
```
## Error codes
| Code | Surface | When thrown |
| --- | --- | --- |
| `AUTH_BA.INVALID_SESSION` | session | unexpected `getSession` failure |
| `AUTH_BA.DB_UNREACHABLE` | db | `betterAuth({...})` construction threw |
| `AUTH_BA.OAUTH_PROVIDER_REJECTED` | oauth | provider returned 403 |
| `AUTH_BA.OAUTH_STATE_MISMATCH` | oauth | state cookie did not match |
| `AUTH_BA.USER_NOT_FOUND` | mapping | custom mapper signalled missing user |
| `AUTH_BA.EMAIL_NOT_VERIFIED` | session | session present but email unverified |
| `AUTH_BA.RATE_LIMITED` | provider | Better-Auth rate-limiter blocked |
| `AUTH_BA.INVALID_REDIRECT` | oauth | redirect URI outside allow-list |
| `AUTH_BA.PRINCIPAL_MAPPING_FAILED` | mapping | mapper threw |
| `AUTH_BA.ROUTE_PREFIX_COLLISION` | mount | prefix collides with host-reserved |
## Conformance
Conformance tests live at:
- `src/session/create-session.spec-conformance.test.ts` — drives
`runAuthSessionConformance` against a Postgres-shaped stubbed
`BetterAuthLike` (the contract asserts the abstract `SessionResolver`
invariants; DB backing is orthogonal).
- `src/provider/create-provider.spec-conformance.test.ts` — drives
`runAuthProviderConformance` against `createBetterAuthProvider` with the
same stubbing topology.
- `tests/integration/postgres-better-auth.test.ts` — real Postgres
container + real Better-Auth + register/login/resolve. Gracefully skips
with a logged reason when Docker / `pg` / schema bootstrap is unavailable
in the runner environment.
## Architecture
```
host config
│
▼
┌─────────────────────────────┐
│ BetterAuthOptions (Zod) │ parseBetterAuthOptions(...)
└──────────────┬──────────────┘
│
per-opts WeakMap
│
▼
┌──────────────┐
│ LazyBetterAuth│ ── betterAuth({ ...args, plugins: [genericOAuth(...)] })
└─────┬────────┘
│ shared (single connection pool)
┌───────┴────────┐
▼ ▼
createBetterAuthSession createBetterAuthProvider
(SessionResolver) (AuthProviderAdapter)
│ │
│ ├── mounts /register, /login, /logout, /refresh,
│ │ /oauth/:provider/start, /oauth/:provider/callback,
│ │ /verify-email/:token, /forgot-password, /reset-password
▼ ▼
Principal auth.handler(rewrittenRequest)
```

View File

@@ -0,0 +1,83 @@
# `database`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lazy holder for the underlying Better-Auth instance plus a helper that
projects {@link BetterAuthOptions} onto the loosely-typed argument bag
`betterAuth(...)` accepts. The lazy pattern matters because Better-Auth's
constructor opens its own DB connection pool — we MUST NOT open it twice
when both the session and provider factories are wired against the same
options, and we MUST NOT do it eagerly at module import time because that
would make `import "@suaveplan/api-auth-better-auth"` block on a network
round-trip.
## Features
- `LazyBetterAuth.get()` builds the Better-Auth instance on first call,
caches it, and serves concurrent callers from the same in-flight promise.
- `LazyBetterAuth.reset()` drops the cache (tests only).
- Factory failures wrapped in `BetterAuthError(AUTH_BA.DB_UNREACHABLE)` with
the underlying error as `cause`.
- In-flight slot is cleared on failure so a transient outage doesn't
permanently poison the cache.
- `buildBetterAuthFactoryArgs(opts)` maps our outer options onto
Better-Auth's shape so the wrapper isn't sprinkled with shape translation.
## Usage: Basic
```ts
import { LazyBetterAuth } from "@suaveplan/api-auth-better-auth";
import { betterAuth } from "better-auth";
const lazy = new LazyBetterAuth(opts, (args) => betterAuth(args));
const auth = await lazy.get();
const response = await auth.handler(request);
```
## Usage: Advanced
```ts
import { LazyBetterAuth, buildBetterAuthFactoryArgs } from "@suaveplan/api-auth-better-auth";
import { betterAuth } from "better-auth";
// Tests can swap in a stubbed factory that records the args + returns a
// stubbed BetterAuthLike. The wrapper's behaviour is exercised without
// standing up a real database.
const captured: Record<string, unknown>[] = [];
const lazy = new LazyBetterAuth(opts, (args) => {
captured.push(args);
return { handler: async () => new Response("ok"), api: { getSession: async () => null } };
});
// Inspect the args the wrapper would have sent to `betterAuth(...)`:
expect(buildBetterAuthFactoryArgs(opts)).toMatchObject({
database: opts.database,
baseURL: opts.baseUrl,
});
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `LazyBetterAuth` | class | One instance per options bag; lazy + cached |
| `buildBetterAuthFactoryArgs(opts)` | `(BetterAuthOptions) => Record<string, unknown>` | Outer-options → Better-Auth-args projection |
## Implementation Notes
The `inflight` slot is the linchpin of the concurrency guarantee — the
first caller to enter `get()` writes a single promise; every subsequent
caller observes that same promise via the second `if`. On rejection the
`finally` clears `inflight` so the next call can retry; on resolution
`this.cached` is set, and the `inflight` clear is harmless. The
session-vs-advanced split in `buildBetterAuthFactoryArgs` mirrors
Better-Auth 1.4's option topology — `session.cookieName` is configured under
`session`, but `sameSite` / `secure` live under
`advanced.defaultCookieAttributes`. Operators wanting the full Better-Auth
option surface (passkeys, magic links, organisation plugin) supply their own
`betterAuthFactory` and call `betterAuth({ ...buildBetterAuthFactoryArgs(opts), plugins: [...] })`
themselves.

View File

@@ -0,0 +1,88 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable, OpenSpec-tracked error-code registry for `@suaveplan/api-auth-better-auth`.
Every fault path inside the adapter — session resolution, database access,
OAuth handshake, principal mapping, route-prefix collision detection — terminates
in a {@link BetterAuthError} carrying one of the ten codes defined here. Codes
are namespaced under `AUTH_BA.*` via the package's `defineCodes` helper so the
full literal value never collides with sibling adapters and consumers can branch
on a single discriminated value.
## Features
- 10 namespaced codes covering session, database, OAuth, mapping and mount-time
failure surfaces.
- `BetterAuthErrorCode` literal-union derived from the code bag — narrows
exhaustively in `switch`.
- `BetterAuthError` class extending `SuaveplanError` so `instanceof` and
structural matching (`err.betterAuthCode`) both work.
## Usage: Basic
```ts
import {
AUTH_BA_CODES,
BetterAuthError,
} from "@suaveplan/api-auth-better-auth/error-codes";
if (!session) {
throw new BetterAuthError(
AUTH_BA_CODES.INVALID_SESSION,
"session resolver returned no principal"
);
}
```
## Usage: Advanced
```ts
import {
AUTH_BA_CODES,
BetterAuthError,
type BetterAuthErrorCode,
} from "@suaveplan/api-auth-better-auth/error-codes";
function describe(code: BetterAuthErrorCode): string {
switch (code) {
case AUTH_BA_CODES.OAUTH_STATE_MISMATCH:
return "Tampered or stale state parameter — restart the flow";
case AUTH_BA_CODES.OAUTH_PROVIDER_REJECTED:
return "The upstream IdP rejected our token request";
default:
return "Unhandled Better-Auth adapter failure";
}
}
try {
await router.handle(request);
} catch (err) {
if (err instanceof BetterAuthError) {
logger.warn(describe(err.betterAuthCode), { code: err.code, cause: err.cause });
}
throw err;
}
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `AUTH_BA_CODES` | `Readonly<Record<Name, "AUTH_BA.${Name}">>` | Frozen bag of every documented code |
| `BetterAuthErrorCode` | string-literal union | The exact set of values `code` may take |
| `BetterAuthError` | class extending `SuaveplanError` | Throwable carrying a `BetterAuthErrorCode` |
## Implementation Notes
`defineCodes("AUTH_BA", [...])` from `@suaveplan/error` is the only way to mint
codes — it freezes the bag and produces the string-literal type. Adding a new
code requires (1) an OpenSpec change against the `api-auth-better-auth`
capability so the spec delta lists the new code, (2) editing the tuple here,
and (3) editing every consumer `switch` block. The redundant
`betterAuthCode` field mirrors `code` so transport boundaries that drop the
prototype chain (worker, IPC) can still match structurally.

View File

@@ -0,0 +1,77 @@
# `session-without-provider`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pin the design invariant from The spec change: the Better-Auth
adapter ships two factories — `createBetterAuthSession` and
`createBetterAuthProvider` — and operators MUST be able to wire either side
without dragging in the other. Wiring only the session adapter (keeping
account-lifecycle elsewhere — e.g. Clerk or a custom rolled provider) is the
"hosted IdP for accounts, Better-Auth for session reads against a shared
cookie" topology the design doc §2.3 calls out. Wiring only the provider
adapter (no Better-Auth session resolution) is rarer but legal — the host
fills the `auth.session` slot with whatever other resolver is configured.
## Features
- Sibling test file pins the invariant by:
- Asserting `createBetterAuthSession(opts)` returns a `SessionResolver`
with no `routes` / `pathPrefix` field.
- Asserting `createBetterAuthProvider(opts)` returns an
`AuthProviderAdapter` with no `resolve` / `dispose` field.
- Walking the recorded route list when `routes(...)` IS invoked to verify
every canonical name lands.
- `betterAuthFactories` constant re-exports both factories as a single bag
so consumer documentation can name the pair without repeating imports.
## Usage: Basic
```ts
import { betterAuthFactories } from "@suaveplan/api-auth-better-auth";
const resolver = betterAuthFactories.session(opts);
const provider = betterAuthFactories.provider(opts);
```
## Usage: Advanced
```ts
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";
import { ClerkSessionResolver } from "third-party/clerk-suaveplan-adapter";
// Topology: Clerk owns sign-up / sign-in routes; Better-Auth reads the
// shared session cookie via createBetterAuthSession against a shadow
// Better-Auth DB that mirrors the Clerk user store. Provider side NOT wired.
host.registerAuth({
resolver: new SessionResolverChain({
resolvers: [
createBetterAuthSession({ database: pool, baseUrl, secret }),
new ClerkSessionResolver({ clerkApiKey: process.env.CLERK_KEY! }),
],
}),
});
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `betterAuthFactories` | `Readonly<{ session, provider }>` | Convenience bag pairing both factories |
## Implementation Notes
The module is intentionally near-empty — the invariant lives in the test
file. The thin re-export bag is the only runtime contribution because the
OpenSpec scaffolding rule requires every implementation file to have a
sibling `.md` AND a paired test file. The independence is enforced
structurally by TypeScript anyway — `SessionResolver` and
`AuthProviderAdapter` have disjoint shapes — but the test pin guards against
a refactor that accidentally adds an `if-not-mounted-mount-the-other-side`
side effect inside either factory. The OpenSpec spec delta lists this file
as the verification for the "session-only / provider-only wiring exposes
nothing from the other side" scenario, so the test names match the spec
exactly.

View File

@@ -0,0 +1,83 @@
# `instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lazily-bound OpenTelemetry instrument bundle for the Better-Auth adapter.
Built once per process against the current global `MeterProvider` and cached.
Tests call {@link resetBetterAuthInstruments} after installing a fresh
`MeterProvider` so assertions only see the instruments emitted by the unit
under test.
## Features
- Four canonical instruments matching the spec's OTel surface contract:
`auth.better_auth.sessions_resolved_total`,
`auth.better_auth.session_resolve_duration_ms`,
`auth.better_auth.oauth_flows_total`,
`auth.better_auth.principal_mapping_errors_total`.
- Singleton-per-process pattern so consumers can call
`getBetterAuthInstruments()` from anywhere without worrying about meter
proliferation.
- Reset helper for test isolation.
## Usage: Basic
```ts
import { getBetterAuthInstruments } from "@suaveplan/api-auth-better-auth/instruments";
const ins = getBetterAuthInstruments();
ins.sessionsResolvedTotal.add(1, { outcome: "resolved" });
ins.sessionResolveDurationMs.record(elapsedMs, { outcome: "resolved" });
```
## Usage: Advanced
```ts
import {
getBetterAuthInstruments,
resetBetterAuthInstruments,
} from "@suaveplan/api-auth-better-auth/instruments";
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
// In test setup
const provider = new MeterProvider({ /* reader, ... */ });
metrics.setGlobalMeterProvider(provider);
resetBetterAuthInstruments(); // force the cache to rebind to the new provider
// In test
const ins = getBetterAuthInstruments();
ins.oauthFlowsTotal.add(1, { provider: "github", outcome: "state_mismatch" });
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `BetterAuthInstruments` | interface | Bundle of `Counter` / `Histogram` instances |
| `getBetterAuthInstruments()` | `() => BetterAuthInstruments` | Lazy cached accessor |
| `resetBetterAuthInstruments()` | `() => void` | Drop the cache (tests only) |
Instrument labels:
| Instrument | Labels | Semantics |
| --- | --- | --- |
| `auth.better_auth.sessions_resolved_total` | `outcome ∈ "resolved" \| "null" \| "error"` | Session-resolver call count |
| `auth.better_auth.session_resolve_duration_ms` | `outcome` (same) | Duration histogram |
| `auth.better_auth.oauth_flows_total` | `provider`, `outcome ∈ "success" \| "state_mismatch" \| "provider_rejected"` | OAuth flow conclusion count |
| `auth.better_auth.principal_mapping_errors_total` | none | Mapper-threw count |
## Implementation Notes
The meter is acquired via `metrics.getMeter("@suaveplan/api-auth-better-auth")`
to match the OpenTelemetry naming convention (instrument scope = package
name). Caching is critical — calling `meter.createCounter(name, ...)` more
than once for the same `name` produces a stable instrument under the OTel
spec, but the SDK MAY emit a warning, and re-binding the cached objects on
every `add()` call would generate measurable overhead. The reset helper
exists purely for tests because the global `MeterProvider` IS the cache key
that decides whether the cached instruments still point at the live provider.

View File

@@ -0,0 +1,78 @@
# `default-factory`
## Purpose
Default `betterAuthFactory` used when an operator does not supply one via
`BetterAuthOptions.betterAuthFactory`. Dynamically imports `better-auth`
AND (when OAuth providers are configured) the `genericOAuth` plugin, then
calls `betterAuth({...})` with the assembled args. The dynamic imports
mean module-load of `@suaveplan/api-auth-better-auth` never blocks on
`better-auth` — operators that ship a stubbed factory (unit tests, custom
adapter shells) avoid the upstream package altogether.
## Features
- Dynamic `import("better-auth")` keeps cold-start fast for stub-only
consumers.
- Dynamic `import("better-auth/plugins/generic-oauth")` only fires when the
options actually configure OAuth providers.
- The Suaveplan-private `__suaveplan_generic_oauth` sentinel key from
`buildBetterAuthFactoryArgs` is stripped before forwarding so the upstream
package never sees our internal fields.
- Pre-existing `plugins` arrays the operator may pass through their own
factory are preserved (the genericOAuth plugin is appended, not assigned).
## Usage: Basic
```ts
// Production wiring — pass options without a betterAuthFactory and the
// default fires implicitly:
const provider = createBetterAuthProvider({
database: pool,
baseUrl: "https://api.example.com",
secret: process.env.BETTER_AUTH_SECRET!,
});
```
## Usage: Advanced
```ts
import { defaultBetterAuthFactory } from "@suaveplan/api-auth-better-auth";
// Operator-supplied factory that wraps the default to inject the
// `organization` Better-Auth plugin on top of whatever the wrapper itself
// already configures:
const customFactory = async (args: Record<string, unknown>) => {
const auth = await defaultBetterAuthFactory(args);
return auth;
};
const provider = createBetterAuthProvider({
database: pool,
baseUrl,
secret,
betterAuthFactory: customFactory,
});
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `defaultBetterAuthFactory(args)` | `(Record<string, unknown>) => Promise<BetterAuthLike>` | Dynamic loader + plugin assembler |
## Implementation Notes
The sentinel-key pattern (`__suaveplan_generic_oauth`) is the trade-off
between (a) keeping `buildBetterAuthFactoryArgs` synchronous and pure, and
(b) actually loading the upstream plugin module. The args bag is built
synchronously so callers and tests can inspect it; the plugin module is
loaded lazily inside this factory. Stripping the sentinel keeps the
boundary clean — Better-Auth's option validator complains about unknown
top-level keys.
The `plugins` array preservation matters because operators MAY supply a
custom factory that adds Better-Auth's own plugins (organisation, passkeys,
2FA, magic-link) on top of the OAuth config the wrapper itself injects. By
appending we ensure a stub operator factory + the wrapper's genericOAuth
config coexist correctly.

View File

@@ -0,0 +1,81 @@
# `github`
## Purpose
Project Suaveplan's {@link BetterAuthOauthProvider} entries onto the
`genericOAuth` plugin config Better-Auth consumes. We use `genericOAuth`
(not the bundled `github` social-provider) for a deliberate reason: the
social-provider hard-codes GitHub's production token endpoint URL, which
cannot be redirected onto a mock IdP — `@suaveplan/testing-integration-oauth-mock`'s
`createMockOauthIdp({ userShape: "github" })` exposes a real-HTTP server
shaped like GitHub, but the social-provider would still hit GitHub.com.
`genericOAuth` accepts the full URL triple (`authorizationUrl`, `tokenUrl`,
`userInfoUrl`) so the same wrapper code drives both production GitHub and
the mock fixture — the only difference is the URL set the operator
configures.
## Features
- Default scope sets: `read:user user:email` for `providerId: "github"`,
`openid email profile` for any other provider.
- Operator-supplied `scopes` always wins.
- `providerName` plumbed onto Better-Auth's `name` field when set; omitted
otherwise.
- Returns `undefined` from `buildGenericOauthPluginArgs` when no providers
are configured so the caller skips wiring the plugin entirely.
## Usage: Basic
```ts
import { buildGenericOauthPluginArgs } from "@suaveplan/api-auth-better-auth";
const pluginArgs = buildGenericOauthPluginArgs({
github: {
providerId: "github",
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
},
});
```
## Usage: Advanced
```ts
import { buildGenericOauthProviderConfig } from "@suaveplan/api-auth-better-auth";
import { createMockOauthIdp } from "@suaveplan/testing-integration-oauth-mock";
// Integration test: same provider shape, mock URLs.
const idp = await createMockOauthIdp({ userShape: "github" });
const cfg = buildGenericOauthProviderConfig({
providerId: "github",
clientId: "test-cid",
clientSecret: "test-csec",
authorizationUrl: `${idp.url}/authorize`,
tokenUrl: `${idp.url}/token`,
userInfoUrl: `${idp.url}/userinfo`,
});
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `buildGenericOauthProviderConfig(p)` | `(BetterAuthOauthProvider) => Record<string, unknown>` | Single provider → genericOAuth config entry |
| `buildGenericOauthPluginArgs(providers)` | `(Record<string, BetterAuthOauthProvider> \| undefined) => Record<string, unknown> \| undefined` | Full bag → `{ config: [...] }` |
## Implementation Notes
The default GitHub scopes mirror the upstream `github` social-provider's
defaults — `read:user` for the `/user` endpoint, `user:email` for
`/user/emails` (Better-Auth's `genericOAuth` plugin only hits the
configured `userInfoUrl`, but real GitHub still expects the broader scope
set when the operator later switches to the native provider). The
OIDC-compliant default (`openid email profile`) covers Google, Okta, Keycloak
and most other discovery-doc-flavoured IdPs and was the safest "I don't
know which provider, give me an email and a stable id" fallback. The
`providerId` field is the discriminant the upstream plugin matches against
the URL params — `:provider` in `/oauth/:provider/start` MUST match an
entry's `providerId` for the route to resolve.

View File

@@ -0,0 +1,105 @@
# `options`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod-validated boundary for the Better-Auth adapter's configuration bag. Both
`createBetterAuthSession` and `createBetterAuthProvider` accept the same
{@link BetterAuthOptions} shape so a single options object can be threaded
through host configuration even when the operator wires only one side of the
auth split. Validation fires at factory-construction time so misconfiguration
fails fast at boot instead of surfacing as a confusing runtime error on the
first inbound request.
## Features
- Required fields — `database` (opaque pass-through to Better-Auth),
`baseUrl` (URL-validated), `secret` (>= 16 chars).
- Optional fields — `routePrefix` (`/auth` by default, must start with `/`),
`oauth.providers` (per-provider URL-validated client config),
`session` (cookie tuning), `principalMapping` (pluggable user → Principal).
- Test hook — `betterAuthFactory` lets unit tests stub the underlying
`betterAuth(...)` call without standing up a real database.
## Usage: Basic
```ts
import { parseBetterAuthOptions } from "@suaveplan/api-auth-better-auth/options";
const opts = parseBetterAuthOptions({
database: pool, // Better-Auth-shaped value (pg.Pool, Kysely dialect, …)
baseUrl: "https://api.example.com",
secret: process.env.BETTER_AUTH_SECRET!,
});
```
## Usage: Advanced
```ts
import { parseBetterAuthOptions } from "@suaveplan/api-auth-better-auth/options";
import type { Principal } from "@suaveplan/api-types/principal";
const opts = parseBetterAuthOptions({
database: pool,
baseUrl: "https://api.example.com",
routePrefix: "/api/auth",
secret: process.env.BETTER_AUTH_SECRET!,
oauth: {
providers: {
github: {
providerId: "github",
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
scopes: ["read:user", "user:email"],
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
},
},
},
session: { cookieName: "ba.session", cookieSameSite: "lax", cookieSecure: true },
principalMapping: ({ user }): Principal => ({
id: user.id as never,
scopes: (user.roles ?? []) as never[],
claims: { email: user.email },
}),
});
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `BetterAuthOptionsSchema` | Zod schema | Validates the full options object |
| `parseBetterAuthOptions(raw)` | `(unknown) => BetterAuthOptions` | Parse + throw on failure |
| `DEFAULT_ROUTE_PREFIX` | `"/auth"` | Default mount prefix |
| `DEFAULT_COOKIE_NAME` | `"better-auth.session"` | Default cookie name |
| `BetterAuthOptions` | interface | Outer options shape |
| `BetterAuthOauthProvider` | interface | Per-provider OAuth config |
| `BetterAuthSessionOptions` | interface | Cookie tuning bag |
| `BetterAuthUserView` / `BetterAuthSessionRecord` / `BetterAuthSessionView` | interfaces | Mapper input shape |
| `BetterAuthLike` | interface | Structural slice of `better-auth`'s `Auth` |
| `PrincipalMapping` | function type | `(view) => Principal` |
## Implementation Notes
`database` is intentionally typed as `unknown` and the schema only checks
non-null/non-undefined — Better-Auth itself supports `pg.Pool`, Kysely
`Dialect`, `D1Database`, `DBAdapterInstance` and several other shapes; the
wrapper never inspects this value beyond passing it through to
`betterAuth({ database })`. This keeps the wrapper one-driver-agnostic and
lets operators bring their own connection topology (transaction-pool, single
client, serverless adapter, …) without the wrapper opinionating on it.
`secret` enforces a 16-character floor because Better-Auth uses it for cookie
signing and shorter secrets are trivially brute-forced. The exact constant is
informational — Better-Auth itself would still accept shorter strings — so
the strict check here is a defence-in-depth guard at the wrapper boundary.
`betterAuthFactory` is the only sanctioned way to inject a stubbed
Better-Auth instance into the adapter. Production callers MUST omit it so the
real `betterAuth(...)` import drives the runtime; tests use it to swap in a
synchronous in-memory stub conforming to {@link BetterAuthLike}.

View File

@@ -0,0 +1,77 @@
# `map-user-to-principal`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Default Better-Auth `(user, session)` → `Principal` mapping consumed by both
`createBetterAuthSession` and `createBetterAuthProvider`. Pure function; given
the same view it always returns the same `Principal`. Operators MAY supply a
custom `principalMapping` in `BetterAuthOptions` to project Better-Auth's
user row onto a custom claims topology, but the default is good enough for
most deployments — it drops sensitive fields, brands `id` and `scopes`, and
prefers `session.activeOrganizationId` over `user.tenantId` so the
organisation plugin's tenant scoping wins automatically when wired.
## Features
- `id` ← `user.id` branded as `PrincipalId`.
- `scopes` ← `user.roles` branded as `Scope[]` (empty array when absent).
- `tenantId` ← `session.activeOrganizationId` → `user.tenantId` fallback.
- `claims` ← rest of `user` minus sensitive fields; `session_id` appended.
- Throws `BetterAuthError(AUTH_BA.PRINCIPAL_MAPPING_FAILED)` on missing id.
- Returned `Principal` and its `claims` are frozen.
## Usage: Basic
```ts
import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-auth-better-auth/principal";
const principal = mapBetterAuthUserToPrincipal({
user: { id: "u_1", email: "a@b.c", roles: ["admin"] },
session: { id: "s_1" },
});
// → { id: "u_1", scopes: ["admin"], claims: { id, email, roles, session_id } }
```
## Usage: Advanced
```ts
import type { Principal } from "@suaveplan/api-types/principal";
import type { PrincipalMapping } from "@suaveplan/api-auth-better-auth/options";
const customMapping: PrincipalMapping = ({ user, session }): Principal => ({
id: `tenant_${session.activeOrganizationId}_${user.id}` as never,
scopes: ((user.roles ?? []) as readonly string[]).flatMap((role) =>
role === "admin"
? ["billing:write", "billing:read", "users:write"]
: [role]
) as never[],
claims: { email: user.email, source: "better-auth" },
});
// Hand the custom mapping through BetterAuthOptions:
// { ..., principalMapping: customMapping }
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `mapBetterAuthUserToPrincipal(view)` | `PrincipalMapping` | Default mapping consumed by both factories |
## Implementation Notes
The frozen-object return shape (`Object.freeze` on both the `Principal` and
its `claims` bag) defends against handlers downstream mutating a single
returned principal and observing the mutation on later requests — the same
hardening applied by `@suaveplan/api-auth-static-principal`. The sensitive
field list (`password`, `passwordHash`, `salt`, `twoFactorSecret`,
`magicLinkToken`, `resetPasswordToken`) covers Better-Auth's stock plugin
set; operators who define additional sensitive fields via a custom
Better-Auth plugin SHOULD supply a custom `principalMapping` to drop those
too. The mapping deliberately does NOT translate roles into a stable
scope vocabulary — that's a deployment concern; the default is identity so
the topology stays predictable.

View File

@@ -0,0 +1,110 @@
# `create-provider`
## Purpose
Factory for the `auth.provider` half of The split. Returns an
`AuthProviderAdapter` (per `@suaveplan/api-auth-provider`) that mounts the
canonical `PROVIDER_ROUTE_SURFACE` (9 routes) under `routePrefix` (default
`/auth`). Each handler internally rewrites the incoming `Request` onto the
Better-Auth-native sub-path via `ROUTE_TRANSLATION` and delegates to
`auth.handler(...)`. This indirection means downstream consumers of the
provider see the canonical foundation surface — never Better-Auth's idioms —
while Better-Auth still owns the precise wire response (cookies, 302
redirects, error envelopes).
## Features
- `id: "better-auth"`; `pathPrefix` validated as `AuthPathPrefix`.
- All 9 canonical route names registered:
- `POST /register` → `POST /sign-up/email`
- `POST /login` → `POST /sign-in/email`
- `POST /logout` → `POST /sign-out`
- `POST /refresh` → `GET /get-session`
- `GET /oauth/:provider/start` → `POST /oauth2/sign-in/:provider`
- `GET /oauth/:provider/callback` → `GET /oauth2/callback/:provider`
- `GET /verify-email/:token` → `GET /verify-email`
- `POST /forgot-password` → `POST /forget-password`
- `POST /reset-password` → `POST /reset-password`
- `routePrefix` collision-checked against
`@suaveplan/api-host/reserved-prefix` — refused with
`AUTH_BA.ROUTE_PREFIX_COLLISION` on collision.
- Better-Auth instance materialised during `lifecycle.install` so DB
unreachable errors fail before traffic.
- OAuth flow conclusions emit `oauth_flows_total{provider,outcome}`.
- Per-options `WeakMap` so the same Better-Auth instance is shared with
`createBetterAuthSession(opts)` when wired with the same options object.
## Usage: Basic
```ts
import { createBetterAuthProvider } from "@suaveplan/api-auth-better-auth";
const provider = createBetterAuthProvider({
database: pool,
baseUrl: "https://api.example.com",
secret: process.env.BETTER_AUTH_SECRET!,
});
// Mount via the host's plugin lifecycle:
host.registerAuthProvider(provider);
```
## Usage: Advanced
```ts
import { createBetterAuthProvider } from "@suaveplan/api-auth-better-auth";
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";
// Operators MUST share the SAME options bag between the two factories to
// share the Better-Auth instance (and thus the DB pool):
const opts = {
database: pool,
baseUrl: "https://api.example.com",
secret: process.env.BETTER_AUTH_SECRET!,
routePrefix: "/api/auth",
oauth: {
providers: {
github: {
providerId: "github",
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
scopes: ["read:user", "user:email"],
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
},
},
},
};
host.registerAuth({
resolver: createBetterAuthSession(opts),
csrf: true,
});
host.registerAuthProvider(createBetterAuthProvider(opts));
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `createBetterAuthProvider(opts)` | `(BetterAuthOptions) => AuthProviderAdapter` | Build the provider |
| `ROUTE_TRANSLATION` | `Record<ProviderRouteName, NativeRoute>` | Re-exported for callers wanting to introspect the mapping |
| `buildNativeUrl(args)` | function | Re-exported for advanced wrappers |
## Implementation Notes
The provider's `routes(router, ctx)` mounts every canonical entry from
`PROVIDER_ROUTE_SURFACE` with `auth: null` because the routes are mounted
INSIDE the auth provider's own prefix and the routes themselves are the
authentication flow — they MUST be reachable unauthenticated. The handler's
inner rewrite reads `(ctx as { params })` because the abstract
`RequestContext` doesn't declare `params`, but the request pipeline driver
(`@suaveplan/api-host/request-pipeline-driver.ts`) attaches them at runtime.
The cast is documented inline. The OAuth outcome heuristic maps status codes
to labels — 2xx/3xx → `success`, 403 → `provider_rejected`, anything else
→ `state_mismatch`. The semantic split between "state_mismatch" and
"provider_rejected" is intentional even though a single 400 covers both
wire-wise: the conformance suite distinguishes the two via the underlying
mock IdP's `primeError` API. The per-options `WeakMap` is keyed by object
identity, not deep equality — sharing requires literal same-object reuse.

View File

@@ -0,0 +1,75 @@
# `route-translation`
## Purpose
Translation table mapping every entry in `PROVIDER_ROUTE_SURFACE` (the
canonical 9-route foundation surface) to the matching Better-Auth-native
sub-path. The wrapper presents the canonical names outward while Better-Auth
still drives the heavy lifting at its idiomatic paths
(`/sign-up/email`, `/sign-in/email`, `/oauth2/sign-in/:providerId`, ...).
Without this translation a host built on the foundation contracts would
need to know Better-Auth-specific URLs — which would defeat the purpose of
the abstract `auth.provider` capability.
## Features
- Single immutable map keyed by `ProviderRouteName`.
- Carries both `nativePath` and `nativeMethod` so the wrapper can rewrite
the request method too (some translations flip GET → POST).
- `buildNativeUrl` helper rewrites the canonical request URL onto
Better-Auth's base URL, resolving `:provider` / `:token` params and
preserving the query string.
## Usage: Basic
```ts
import { ROUTE_TRANSLATION, buildNativeUrl } from "@suaveplan/api-auth-better-auth";
const target = ROUTE_TRANSLATION.login;
// → { nativePath: "/sign-in/email", nativeMethod: "POST" }
const url = buildNativeUrl({
baseUrl: "https://api.example.com",
nativePath: target.nativePath,
params: {},
search: "redirect=/home",
});
// → URL { https://api.example.com/sign-in/email?redirect=/home }
```
## Usage: Advanced
```ts
import { buildNativeUrl, ROUTE_TRANSLATION } from "@suaveplan/api-auth-better-auth";
// OAuth callback — the canonical `/oauth/:provider/callback?code=xxx&state=yyy`
// is rewritten to Better-Auth's `/oauth2/callback/:provider?...` surface.
const target = ROUTE_TRANSLATION["oauth.callback"];
const native = buildNativeUrl({
baseUrl: "https://api.example.com",
nativePath: target.nativePath,
params: { provider: "github" },
search: "code=xxx&state=yyy",
});
// → URL { https://api.example.com/oauth2/callback/github?code=xxx&state=yyy }
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `NativeRoute` | interface | `{ nativePath, nativeMethod }` |
| `ROUTE_TRANSLATION` | `Readonly<Record<ProviderRouteName, NativeRoute>>` | Canonical → native map |
| `buildNativeUrl(args)` | function | Rewrite the canonical URL onto Better-Auth's base URL |
## Implementation Notes
`Object.freeze` on the outer record + a `Readonly<...>` cast — but not on
the inner entries because each entry is already a frozen literal authored
inline. Decided against `as const` on the outer block so the indexed key
narrowing stays type-friendly under `noPropertyAccessFromIndexSignature`.
`buildNativeUrl` runs `:param` substitution before constructing the URL so
malformed param values can't break out of the path; `encodeURIComponent`
defends against `/` injection in the `:provider` slot. Query string
preservation is critical for OAuth — the entire upstream callback payload
(`code`, `state`, `scope`, …) needs to land on Better-Auth's handler verbatim.

View File

@@ -0,0 +1,79 @@
# `route-prefix`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Resolve an operator-supplied `routePrefix` against the host's reserved-prefix
contract from `@suaveplan/api-host/reserved-prefix`. This guard ensures the
Better-Auth provider cannot accidentally mount its account-lifecycle routes
inside paths the host (or another first-party engine) reserves for itself —
`/_host` (admin sub-router) and `/internal` (livez/readyz/metrics) today,
plus whatever future entries land in `HOST_RESERVED_PREFIXES`. The check is
performed at provider construction time so misconfiguration fails before the
process is accepting traffic.
## Features
- Normalised prefix returned on success (lowercase, leading slash, no
trailing slash) so downstream code can use it without re-normalising.
- Collision detection covers both exact matches (`/internal`) and child
paths (`/internal/livez`).
- Reserved list NOT hard-coded — sourced from `@suaveplan/api-host` so the
wrapper inherits future additions for free.
- Throws `BetterAuthError(AUTH_BA.ROUTE_PREFIX_COLLISION)` with the
normalised collision string in `context.prefix`.
## Usage: Basic
```ts
import { assertRoutePrefixAvailable } from "@suaveplan/api-auth-better-auth";
const prefix = assertRoutePrefixAvailable("/auth"); // → "/auth"
```
## Usage: Advanced
```ts
import { assertRoutePrefixAvailable } from "@suaveplan/api-auth-better-auth";
import { BetterAuthError, AUTH_BA_CODES } from "@suaveplan/api-auth-better-auth/error-codes";
try {
const prefix = assertRoutePrefixAvailable(operatorConfig.routePrefix);
return mountProvider({ prefix });
} catch (err) {
if (
err instanceof BetterAuthError &&
err.code === AUTH_BA_CODES.ROUTE_PREFIX_COLLISION
) {
// The deploy script can report the collision back to the operator
// before traffic is accepted.
reportFatalConfigError({ requested: operatorConfig.routePrefix, code: err.code });
}
throw err;
}
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `assertRoutePrefixAvailable(prefix)` | `(string) => string` | Validates + returns normalised prefix |
The function throws `BetterAuthError(AUTH_BA.ROUTE_PREFIX_COLLISION)` when
the prefix collides with `HOST_RESERVED_PREFIXES` from
`@suaveplan/api-host/reserved-prefix`.
## Implementation Notes
The lazy `codeFactory` callback pattern from `@suaveplan/api-host`'s
`assertPrefixAvailable` API is critical here — the factory is only invoked on
collision, so the `BetterAuthError` allocation (with its stack trace and
context bag) is skipped on the happy path. The reserved-prefix module also
exports `normalisePrefix` separately; this wrapper calls it to compute the
returned canonical form because `assertPrefixAvailable` returns `void`. This
matches the pattern in `@suaveplan/api-plugin-rbac` and other adapter
packages that need both the throw-on-collision behaviour and the normalised
output value at the call site.

View File

@@ -0,0 +1,94 @@
# `create-session`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the `auth.session` half of The split. Returns a
`SessionResolver` that asks Better-Auth's `auth.api.getSession({ headers })`
to materialise the current session and then maps the resulting `(user,
session)` tuple onto a `Principal` per `@suaveplan/api-types`. Returns `null`
when Better-Auth reports no session — the host's resolver chain advances to
the next adapter (typically `api-auth-anonymous`).
## Features
- Implements the `SessionResolver` contract from `@suaveplan/api-auth`.
- Pluggable `principalMapping` via `BetterAuthOptions.principalMapping`;
default `mapBetterAuthUserToPrincipal`.
- Lazy + cached Better-Auth instance — shared with
`createBetterAuthProvider` when both factories receive the same options
object (per-`opts` WeakMap).
- OTel instruments emitted on every resolve: `sessions_resolved_total{outcome}`,
`session_resolve_duration_ms{outcome}`,
`principal_mapping_errors_total`.
- Stable failure modes: `INVALID_SESSION`, `DB_UNREACHABLE`,
`PRINCIPAL_MAPPING_FAILED`.
- Disposable — `dispose()` is idempotent and turns subsequent `resolve()`
calls into `null` returns.
## Usage: Basic
```ts
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";
const resolver = createBetterAuthSession({
database: pool,
baseUrl: "https://api.example.com",
secret: process.env.BETTER_AUTH_SECRET!,
});
// Inside the host's resolver chain:
const principal = await resolver.resolve(request);
```
## Usage: Advanced
```ts
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
import { createAnonymousResolver } from "@suaveplan/api-auth-anonymous";
const resolver = new SessionResolverChain({
resolvers: [
createBetterAuthSession(opts),
createAnonymousResolver(),
],
});
// The Better-Auth resolver fires first; if it returns `null`, the
// anonymous tail takes over and the host's `publicRoutes` policy applies.
```
## API Reference
| Export | Shape | Purpose |
| ------ | ----- | ------- |
| `createBetterAuthSession(opts)` | `(BetterAuthOptions) => SessionResolver` | Build the resolver |
The resolver throws `BetterAuthError` with one of:
- `AUTH_BA.INVALID_SESSION` — unexpected upstream error from Better-Auth's
`getSession`.
- `AUTH_BA.DB_UNREACHABLE` — first-resolve build failed (wraps the
underlying connection error as `cause`).
- `AUTH_BA.PRINCIPAL_MAPPING_FAILED` — `principalMapping` threw; a partial
principal is never returned.
## Implementation Notes
`getSession` is consumed via the structural `BetterAuthLike` view declared
in `../options/options.types.ts`. That view is intentionally narrow — only
`handler` and `api.getSession` are referenced — so the unit tests can stub
Better-Auth without touching the real runtime. The per-options WeakMap
(`lazyByOptions`) is the linchpin of the "share an instance between session
and provider" guarantee: passing the same `opts` object to both factories
threads through the same `LazyBetterAuth`. The OTel `finally` block runs on
every code path so the counters reflect every attempt, including failures
that propagate as `BetterAuthError`. `dispose()` resets the cached
Better-Auth instance via `lazy.reset()` and flips a disposed flag that
neutralises any subsequent `resolve()` call. The default Better-Auth factory
dynamically imports `better-auth` so consumers wiring a `betterAuthFactory`
override avoid the cost of loading the upstream package altogether.

View File

@@ -0,0 +1,90 @@
# `testing`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/api-auth-better-auth/testing` is the subpath downstream packages
import when they want to assert their own wiring layer still meets the
abstract `auth.session` and `auth.provider` contracts on top of this
adapter. It re-exports both conformance harnesses
(`runAuthSessionConformance` from `@suaveplan/api-auth` and
`runAuthProviderConformance` from `@suaveplan/api-auth-provider`) alongside
the two Better-Auth factories from this package, so a single import gets
the consumer everything they need.
## Features
- Re-exports `runAuthSessionConformance` + `runAuthProviderConformance` from
the contract packages so consumers don't have to add those packages to
their own dependency closure.
- Re-exports `createBetterAuthSession` + `createBetterAuthProvider`
so the conformance call sites can drop the factory inline.
- `AuthSessionConformanceFactory` / `AuthProviderConformanceFactory` /
`AuthProviderConformanceOptions` type aliases for explicit typing.
## Usage: Basic
```ts
import {
createBetterAuthProvider,
runAuthProviderConformance,
} from "@suaveplan/api-auth-better-auth/testing";
const results = runAuthProviderConformance({
label: "my-host's Better-Auth wiring",
factory: () => createBetterAuthProvider(myOptions),
});
for (const scenario of results) {
it(`${scenario.category} :: ${scenario.name}`, () => {
expect(scenario.passed).toBe(true);
});
}
```
## Usage: Advanced
```ts
import {
createBetterAuthSession,
runAuthSessionConformance,
type AuthSessionConformanceFactory,
} from "@suaveplan/api-auth-better-auth/testing";
const spec: AuthSessionConformanceFactory = {
label: "Postgres-backed Better-Auth + my custom mapping",
factory: () => createBetterAuthSession(myOptionsWithCustomMapping),
seedNull: () => new Request("https://api.example.com/anon"),
seedPrincipal: () => ({
request: new Request("https://api.example.com/me", {
headers: { cookie: "better-auth.session=valid" },
}),
expected: knownPrincipal,
}),
};
runAuthSessionConformance(spec);
```
## API Reference
| Export | Source | Purpose |
| ------ | ------ | ------- |
| `runAuthSessionConformance` | `@suaveplan/api-auth` | Session contract harness |
| `runAuthProviderConformance` | `@suaveplan/api-auth-provider` | Provider contract harness |
| `createBetterAuthSession` | this package | Session factory under test |
| `createBetterAuthProvider` | this package | Provider factory under test |
| `AuthSessionConformanceFactory` | type | Spec passed to session harness |
| `AuthProviderConformanceFactory` | type | Factory signature for provider harness |
| `AuthProviderConformanceOptions` | type | Options for provider harness |
## Implementation Notes
The subpath is intentionally light — most of the value is just having one
import path consumers can pull from. The conformance harnesses themselves
live in the contract packages because that's where the abstract invariants
are authored; this package's contribution to "passing conformance" is the
factory pair. Consumers test their own wiring layer (custom middleware,
host configuration, plugin order) by invoking the harnesses with their
own options bag and asserting every `ConformanceScenarioResult.passed`.