Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

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,171 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import type {
BetterAuthLike,
BetterAuthOptions,
} from "../options/options.types.js";
import { buildBetterAuthFactoryArgs, LazyBetterAuth } from "./database.js";
function stubBetterAuthLike(): BetterAuthLike {
return {
handler: async () => new Response("ok"),
api: { getSession: async () => null },
};
}
const baseOpts: BetterAuthOptions = {
database: { kind: "stub" },
baseUrl: "https://api.example.com",
secret: "0123456789abcdef0123456789abcdef",
};
describe("LazyBetterAuth", () => {
it("calls the factory exactly once across concurrent get() callers", {
timeout: 5000,
}, async () => {
let calls = 0;
const lazy = new LazyBetterAuth(baseOpts, async () => {
calls += 1;
return stubBetterAuthLike();
});
const [a, b, c] = await Promise.all([
lazy.get(),
lazy.get(),
lazy.get(),
]);
expect(calls).toBe(1);
expect(a).toBe(b);
expect(b).toBe(c);
});
it("returns the cached value on the second await chain", {
timeout: 5000,
}, async () => {
let calls = 0;
const lazy = new LazyBetterAuth(baseOpts, () => {
calls += 1;
return stubBetterAuthLike();
});
const a = await lazy.get();
const b = await lazy.get();
expect(a).toBe(b);
expect(calls).toBe(1);
});
it("reset() forces a fresh build on the next call", {
timeout: 5000,
}, async () => {
let calls = 0;
const lazy = new LazyBetterAuth(baseOpts, () => {
calls += 1;
return stubBetterAuthLike();
});
await lazy.get();
lazy.reset();
await lazy.get();
expect(calls).toBe(2);
});
it("wraps factory failure in AUTH_BA.DB_UNREACHABLE", {
timeout: 5000,
}, async () => {
const lazy = new LazyBetterAuth(baseOpts, () => {
throw new Error("ECONNREFUSED");
});
try {
await lazy.get();
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BetterAuthError);
expect((err as BetterAuthError).code).toBe(
AUTH_BA_CODES.DB_UNREACHABLE
);
expect((err as BetterAuthError).cause).toBeInstanceOf(Error);
}
});
it("clears the in-flight slot on failure so a retry can succeed", {
timeout: 5000,
}, async () => {
let calls = 0;
const lazy = new LazyBetterAuth(baseOpts, () => {
calls += 1;
if (calls === 1) throw new Error("transient");
return stubBetterAuthLike();
});
await expect(lazy.get()).rejects.toBeInstanceOf(BetterAuthError);
const second = await lazy.get();
expect(second).toBeDefined();
expect(calls).toBe(2);
});
});
describe("buildBetterAuthFactoryArgs", () => {
it("plumbs the base fields", { timeout: 5000 }, () => {
const args = buildBetterAuthFactoryArgs(baseOpts);
expect(args["database"]).toBe(baseOpts.database);
expect(args["baseURL"]).toBe("https://api.example.com");
expect(args["secret"]).toBe(baseOpts.secret);
expect(args["emailAndPassword"]).toEqual({ enabled: true });
});
it("plumbs session cookie tuning into Better-Auth shape", {
timeout: 5000,
}, () => {
const args = buildBetterAuthFactoryArgs({
...baseOpts,
session: {
cookieName: "ba.s",
cookieSameSite: "strict",
cookieSecure: true,
},
});
expect((args["session"] as Record<string, unknown>)["cookieName"]).toBe(
"ba.s"
);
const advanced = args["advanced"] as Record<string, unknown>;
const attrs = advanced["defaultCookieAttributes"] as Record<
string,
unknown
>;
expect(attrs["sameSite"]).toBe("strict");
expect(attrs["secure"]).toBe(true);
});
it("omits session and advanced when no session options supplied", {
timeout: 5000,
}, () => {
const args = buildBetterAuthFactoryArgs(baseOpts);
expect(args["session"]).toBeUndefined();
expect(args["advanced"]).toBeUndefined();
});
it("omits empty advanced when only cookieName is set", {
timeout: 5000,
}, () => {
const args = buildBetterAuthFactoryArgs({
...baseOpts,
session: { cookieName: "ba.s" },
});
expect((args["session"] as Record<string, unknown>)["cookieName"]).toBe(
"ba.s"
);
expect(args["advanced"]).toBeUndefined();
});
it("omits empty session when only cookie attributes are set", {
timeout: 5000,
}, () => {
const args = buildBetterAuthFactoryArgs({
...baseOpts,
session: { cookieSecure: true },
});
expect(args["session"]).toBeUndefined();
const advanced = args["advanced"] as Record<string, unknown>;
const attrs = advanced["defaultCookieAttributes"] as Record<
string,
unknown
>;
expect(attrs["secure"]).toBe(true);
});
});

View File

@@ -0,0 +1,129 @@
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import { buildGenericOauthPluginArgs } from "../oauth/github.js";
import type {
BetterAuthLike,
BetterAuthOptions,
} from "../options/options.types.js";
/**
* Lazy holder for a single Better-Auth instance bound to its own DB
* connection. Shared between `createBetterAuthSession` and
* `createBetterAuthProvider` when an operator wires both — they import the
* same `LazyBetterAuth` instance so they share one underlying connection
* pool. When wired independently, each factory constructs its own
* `LazyBetterAuth` and they hold separate pools (which is fine — Better-Auth
* owns the schema either way, and a deployment that wires only one side is
* already accepting the operational consequences).
*/
export class LazyBetterAuth {
private readonly options: BetterAuthOptions;
private readonly factory: (
opts: Record<string, unknown>
) => BetterAuthLike | Promise<BetterAuthLike>;
private cached: BetterAuthLike | undefined;
private inflight: Promise<BetterAuthLike> | undefined;
public constructor(
options: BetterAuthOptions,
factory: (
opts: Record<string, unknown>
) => BetterAuthLike | Promise<BetterAuthLike>
) {
this.options = options;
this.factory = factory;
}
/**
* Construct (or return the cached) Better-Auth instance. The first
* caller wins; concurrent callers await the same promise so we never
* stand up two connections for the same configuration. Errors thrown by
* the factory are wrapped in `AUTH_BA.DB_UNREACHABLE` to give operators
* a stable code to alert on.
*/
public async get(): Promise<BetterAuthLike> {
if (this.cached !== undefined) return this.cached;
if (this.inflight !== undefined) return this.inflight;
this.inflight = this.build();
try {
this.cached = await this.inflight;
return this.cached;
} finally {
this.inflight = undefined;
}
}
/**
* Replace the cached instance. Tests use this to swap in a stub between
* scenarios without rebuilding the surrounding adapter; production
* callers never call it.
*/
public reset(): void {
this.cached = undefined;
this.inflight = undefined;
}
private async build(): Promise<BetterAuthLike> {
const factoryArgs = buildBetterAuthFactoryArgs(this.options);
try {
return await this.factory(factoryArgs);
} catch (err) {
throw new BetterAuthError(
AUTH_BA_CODES.DB_UNREACHABLE,
"betterAuth({...}) construction failed — the configured Better-Auth database is unreachable or invalid",
{ cause: err, context: { statusCode: 500 } }
);
}
}
}
/**
* Project our outer {@link BetterAuthOptions} onto the loosely-typed
* `Record<string, unknown>` `betterAuth()` itself accepts. The narrow
* mapping is intentional — we expose only the fields the wrapper-level
* spec covers; operators wanting richer Better-Auth behaviour write their
* own `betterAuthFactory` that supplements these args before calling
* `betterAuth({...})`.
*/
export function buildBetterAuthFactoryArgs(
opts: BetterAuthOptions
): Record<string, unknown> {
const args: Record<string, unknown> = {
database: opts.database,
baseURL: opts.baseUrl,
secret: opts.secret,
emailAndPassword: {
enabled: true,
},
};
const oauthPluginArgs = buildGenericOauthPluginArgs(opts.oauth?.providers);
if (oauthPluginArgs !== undefined) {
// The `genericOAuth` plugin is loaded asynchronously via a sentinel
// entry — the caller (`LazyBetterAuth`'s factory wrapper) resolves
// it before invoking `betterAuth(args)`. We store the raw plugin
// args here under a stable key so the wrapper-level factory can
// pull them back out.
args["__suaveplan_generic_oauth"] = oauthPluginArgs;
}
if (opts.session !== undefined) {
const cookieOptions: Record<string, unknown> = {};
if (opts.session.cookieSameSite !== undefined) {
cookieOptions["sameSite"] = opts.session.cookieSameSite;
}
if (opts.session.cookieSecure !== undefined) {
cookieOptions["secure"] = opts.session.cookieSecure;
}
const sessionBag: Record<string, unknown> = {};
if (opts.session.cookieName !== undefined) {
sessionBag["cookieName"] = opts.session.cookieName;
}
if (Object.keys(sessionBag).length > 0) {
args["session"] = sessionBag;
}
if (Object.keys(cookieOptions).length > 0) {
args["advanced"] = {
defaultCookieAttributes: cookieOptions,
};
}
}
return args;
}

View File

@@ -0,0 +1 @@
export { buildBetterAuthFactoryArgs, LazyBetterAuth } from "./database.js";

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,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_BA_CODES,
BetterAuthError,
type BetterAuthErrorCode,
} from "./error-codes.js";
describe("AUTH_BA_CODES registry", () => {
it("exposes 10 distinct namespaced codes", { timeout: 5000 }, () => {
const values = Object.values(AUTH_BA_CODES);
expect(values.length).toBe(10);
expect(new Set(values).size).toBe(10);
for (const value of values) {
expect(value.startsWith("AUTH_BA.")).toBe(true);
}
});
it("includes every documented code", { timeout: 5000 }, () => {
const expected: ReadonlyArray<BetterAuthErrorCode> = [
AUTH_BA_CODES.INVALID_SESSION,
AUTH_BA_CODES.DB_UNREACHABLE,
AUTH_BA_CODES.OAUTH_PROVIDER_REJECTED,
AUTH_BA_CODES.OAUTH_STATE_MISMATCH,
AUTH_BA_CODES.USER_NOT_FOUND,
AUTH_BA_CODES.EMAIL_NOT_VERIFIED,
AUTH_BA_CODES.RATE_LIMITED,
AUTH_BA_CODES.INVALID_REDIRECT,
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION,
];
for (const code of expected) {
expect(typeof code).toBe("string");
}
});
});
describe("BetterAuthError", () => {
it("retains the code on the instance", { timeout: 5000 }, () => {
const err = new BetterAuthError(
AUTH_BA_CODES.INVALID_SESSION,
"invalid session"
);
expect(err.code).toBe(AUTH_BA_CODES.INVALID_SESSION);
expect(err.betterAuthCode).toBe(AUTH_BA_CODES.INVALID_SESSION);
expect(err.message).toBe("invalid session");
expect(err).toBeInstanceOf(BetterAuthError);
expect(err).toBeInstanceOf(Error);
});
it("propagates options to the parent constructor", {
timeout: 5000,
}, () => {
const cause = new Error("boom");
const err = new BetterAuthError(
AUTH_BA_CODES.DB_UNREACHABLE,
"could not reach postgres",
{ cause, context: { host: "localhost" } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ host: "localhost" });
});
});

View File

@@ -0,0 +1,64 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-auth-better-auth`.
*
* Every code is namespaced under `AUTH_BA.*` via {@link defineCodes} so the
* full literal value of {@link AUTH_BA_CODES.INVALID_SESSION} is the string
* `"AUTH_BA.INVALID_SESSION"`. Codes are partitioned by failure surface:
*
* - Session resolution: `INVALID_SESSION`, `PRINCIPAL_MAPPING_FAILED`,
* `USER_NOT_FOUND`, `EMAIL_NOT_VERIFIED`.
* - Database: `DB_UNREACHABLE`.
* - OAuth flow: `OAUTH_PROVIDER_REJECTED`, `OAUTH_STATE_MISMATCH`,
* `INVALID_REDIRECT`.
* - Mount-time validation: `ROUTE_PREFIX_COLLISION`.
* - Rate-limit guard: `RATE_LIMITED`.
*
* Consumers branch on the constants in the {@link AUTH_BA_CODES} bag, never
* on the raw string literals, so the codes can evolve safely via OpenSpec.
*/
export const AUTH_BA_CODES = defineCodes("AUTH_BA", [
"INVALID_SESSION",
"DB_UNREACHABLE",
"OAUTH_PROVIDER_REJECTED",
"OAUTH_STATE_MISMATCH",
"USER_NOT_FOUND",
"EMAIL_NOT_VERIFIED",
"RATE_LIMITED",
"INVALID_REDIRECT",
"PRINCIPAL_MAPPING_FAILED",
"ROUTE_PREFIX_COLLISION",
] as const);
/**
* String-literal union of every code in {@link AUTH_BA_CODES}. Useful as a
* generic argument on {@link SuaveplanError} so `err.code` narrows to the
* exact set rather than the open `string` type.
*/
export type BetterAuthErrorCode =
(typeof AUTH_BA_CODES)[keyof typeof AUTH_BA_CODES];
/**
* Concrete error class for the Better-Auth adapter. Extends
* {@link SuaveplanError} so `instanceof BetterAuthError` narrows `code` to
* {@link BetterAuthErrorCode} and consumers can write exhaustive `switch`
* blocks against the registry.
*
* The `betterAuthCode` instance field is a redundant copy of `code` exposed
* as a discriminator so structural matching (without `instanceof`) is still
* possible across worker / IPC boundaries where the prototype chain is lost.
*/
export class BetterAuthError extends SuaveplanError<BetterAuthErrorCode> {
public readonly betterAuthCode: BetterAuthErrorCode;
public constructor(
code: BetterAuthErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.betterAuthCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
AUTH_BA_CODES,
BetterAuthError,
type BetterAuthErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1 @@
export { betterAuthFactories } from "./session-without-provider.js";

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,126 @@
import type {
CapabilityRequirements,
Middleware,
RouteHandler,
Router,
} from "@suaveplan/api-types";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
BetterAuthLike,
BetterAuthOptions,
} from "../options/options.types.js";
import { createBetterAuthProvider } from "../provider/create-provider.js";
import { createBetterAuthSession } from "../session/create-session.js";
import { betterAuthFactories } from "./session-without-provider.js";
function stubFactory(
view: {
readonly user: { readonly id: string };
readonly session: { readonly id: string };
} | null
): (opts: Record<string, unknown>) => BetterAuthLike {
return () => ({
handler: async () => new Response("ok"),
api: { getSession: async () => view },
});
}
function recordingRouter<Caps extends CapabilityRequirements>(
sink: string[]
): Router<Caps> {
const push = (method: string, path: string) =>
sink.push(`${method} ${path}`);
return {
get: (path, _opts, _handler: RouteHandler) => push("GET", path),
post: (path, _opts, _handler: RouteHandler) => push("POST", path),
put: (path, _opts, _handler: RouteHandler) => push("PUT", path),
patch: (path, _opts, _handler: RouteHandler) => push("PATCH", path),
delete: (path, _opts, _handler: RouteHandler) => push("DELETE", path),
options: (path, _opts, _handler: RouteHandler) => push("OPTIONS", path),
head: (path, _opts, _handler: RouteHandler) => push("HEAD", path),
use: (_middleware: Middleware<Caps>) => undefined,
};
}
const opts: BetterAuthOptions = {
database: { kind: "stub" },
baseUrl: "https://api.example.com",
secret: "0123456789abcdef0123456789abcdef",
betterAuthFactory: stubFactory({
user: { id: "u_1" },
session: { id: "s_1" },
}),
};
describe("session adapter mounts without provider routes", () => {
it("createBetterAuthSession does not register any routes", {
timeout: 5000,
}, async () => {
const resolver = createBetterAuthSession(opts);
try {
// The returned value is a `SessionResolver`, not an
// `AuthProviderAdapter` — there is no `routes(...)` method on it.
expect(
(resolver as unknown as { routes?: unknown }).routes
).toBeUndefined();
expect(
(resolver as unknown as { pathPrefix?: unknown }).pathPrefix
).toBeUndefined();
} finally {
await resolver.dispose();
}
});
it("session resolution still works when no provider is wired", {
timeout: 5000,
}, async () => {
const resolver = createBetterAuthSession(opts);
try {
const principal = await resolver.resolve(
new Request("https://api.example.com/anything", {
headers: { cookie: "better-auth.session=tok" },
})
);
expect(principal?.id).toBe("u_1" as never);
} finally {
await resolver.dispose();
}
});
});
describe("provider adapter mounts without claiming auth.session", () => {
it("createBetterAuthProvider does not expose a SessionResolver", {
timeout: 5000,
}, () => {
const provider = createBetterAuthProvider(opts);
// No `.resolve()` method — operators wire a separate session resolver.
expect(
(provider as unknown as { resolve?: unknown }).resolve
).toBeUndefined();
expect(
(provider as unknown as { dispose?: unknown }).dispose
).toBeUndefined();
// BUT the adapter shape IS present:
expect(provider.id).toBe("better-auth");
expect(typeof provider.routes).toBe("function");
});
it("provider routes() registers every canonical route when invoked", {
timeout: 5000,
}, () => {
const provider = createBetterAuthProvider(opts);
const sink: string[] = [];
provider.routes(recordingRouter(sink), {} as never);
expect(sink).toContain("POST /register");
expect(sink).toContain("GET /oauth/:provider/callback");
// 9 canonical routes total
expect(sink.length).toBe(9);
});
});
describe("betterAuthFactories convenience bag", () => {
it("exposes both factories together", { timeout: 5000 }, () => {
expect(betterAuthFactories.session).toBe(createBetterAuthSession);
expect(betterAuthFactories.provider).toBe(createBetterAuthProvider);
});
});

View File

@@ -0,0 +1,20 @@
/**
* Marker module — kept to satisfy the OpenSpec requirement that every
* feature module has a paired `.md`. The independence guarantee is asserted
* by the sibling test file: the two factories MUST be usable independently
* — wiring `createBetterAuthSession` alone does not mount provider routes,
* and wiring `createBetterAuthProvider` alone does not claim the
* `auth.session` slot.
*/
import { createBetterAuthProvider } from "../provider/create-provider.js";
import { createBetterAuthSession } from "../session/create-session.js";
/**
* Re-exports the two factories as a single value so consumer code can
* destructure them when documenting the "wire both sides" pattern.
*/
export const betterAuthFactories = Object.freeze({
session: createBetterAuthSession,
provider: createBetterAuthProvider,
});

View File

@@ -0,0 +1,56 @@
// Side-effect import — surfaces the error-codes registry to consumers via
// `import "@suaveplan/api-auth-better-auth"`.
import "./error-codes/error-codes.js";
// Database
export { buildBetterAuthFactoryArgs, LazyBetterAuth } from "./db/database.js";
// Error codes
export {
AUTH_BA_CODES,
BetterAuthError,
type BetterAuthErrorCode,
} from "./error-codes/error-codes.js";
// Independence
export { betterAuthFactories } from "./independence/session-without-provider.js";
// Instruments
export {
type BetterAuthInstruments,
getBetterAuthInstruments,
resetBetterAuthInstruments,
} from "./instruments/instruments.js";
// OAuth
export { defaultBetterAuthFactory } from "./oauth/default-factory.js";
export {
buildGenericOauthPluginArgs,
buildGenericOauthProviderConfig,
} from "./oauth/github.js";
// Options
export {
BetterAuthOptionsSchema,
DEFAULT_COOKIE_NAME,
DEFAULT_ROUTE_PREFIX,
parseBetterAuthOptions,
} from "./options/options.js";
export type {
BetterAuthLike,
BetterAuthOauthProvider,
BetterAuthOptions,
BetterAuthSessionOptions,
BetterAuthSessionRecord,
BetterAuthSessionView,
BetterAuthUserView,
PrincipalMapping,
} from "./options/options.types.js";
// Principal
export { mapBetterAuthUserToPrincipal } from "./principal/map-user-to-principal.js";
// Provider
export { createBetterAuthProvider } from "./provider/create-provider.js";
export {
buildNativeUrl,
type NativeRoute,
ROUTE_TRANSLATION,
} from "./provider/route-translation.js";
// Route prefix
export { assertRoutePrefixAvailable } from "./route-prefix/route-prefix.js";
// Session
export { createBetterAuthSession } from "./session/create-session.js";

View File

@@ -0,0 +1,5 @@
export {
type BetterAuthInstruments,
getBetterAuthInstruments,
resetBetterAuthInstruments,
} from "./instruments.js";

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,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
getBetterAuthInstruments,
resetBetterAuthInstruments,
} from "./instruments.js";
describe("getBetterAuthInstruments", () => {
it("returns the same cached bundle across calls", { timeout: 5000 }, () => {
resetBetterAuthInstruments();
const a = getBetterAuthInstruments();
const b = getBetterAuthInstruments();
expect(a).toBe(b);
});
it("emits the four documented instruments", {
timeout: 30000,
}, async () => {
resetBetterAuthInstruments();
const ins = getBetterAuthInstruments();
ins.sessionsResolvedTotal.add(1, { outcome: "resolved" });
ins.sessionResolveDurationMs.record(7, { outcome: "resolved" });
ins.oauthFlowsTotal.add(1, { provider: "github", outcome: "success" });
ins.principalMappingErrorsTotal.add(1);
const collected = await flushTestMetrics();
const names = collected
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("auth.better_auth.sessions_resolved_total");
expect(names).toContain("auth.better_auth.session_resolve_duration_ms");
expect(names).toContain("auth.better_auth.oauth_flows_total");
expect(names).toContain(
"auth.better_auth.principal_mapping_errors_total"
);
});
it("sessions_resolved_total increments on successful resolution", {
timeout: 30000,
}, async () => {
resetBetterAuthInstruments();
const ins = getBetterAuthInstruments();
ins.sessionsResolvedTotal.add(1, { outcome: "resolved" });
const collected = await flushTestMetrics();
const counter = collected
.flatMap((m) => m.scopeMetrics)
.flatMap((s) => s.metrics)
.find(
(m) =>
m.descriptor.name ===
"auth.better_auth.sessions_resolved_total"
);
expect(counter).toBeDefined();
});
});

View File

@@ -0,0 +1,77 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the Better-Auth adapter.
*
* - `sessionsResolvedTotal{outcome}` — counter incremented on every call
* to `auth.session.resolve()`. `outcome ∈ "resolved" | "null" | "error"`.
* - `sessionResolveDurationMs` — histogram of session-resolve duration in ms.
* Tagged with the same `outcome` label.
* - `oauthFlowsTotal{provider,outcome}` — counter incremented at the
* conclusion of every OAuth flow. `outcome ∈ "success" | "state_mismatch"
* | "provider_rejected"`.
* - `principalMappingErrorsTotal` — counter incremented when the
* user-supplied or default `PrincipalMapping` throws.
*/
export interface BetterAuthInstruments {
readonly sessionsResolvedTotal: Counter;
readonly sessionResolveDurationMs: Histogram;
readonly oauthFlowsTotal: Counter;
readonly principalMappingErrorsTotal: Counter;
}
let cached: BetterAuthInstruments | undefined;
const METER_NAME = "@suaveplan/api-auth-better-auth";
/**
* Lazily build (and cache) the adapter instruments bundle against the
* current global `MeterProvider`. Call {@link resetBetterAuthInstruments}
* in test setup to force a re-bind against a freshly-installed provider.
*/
export function getBetterAuthInstruments(): BetterAuthInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter(METER_NAME);
cached = {
sessionsResolvedTotal: meter.createCounter(
"auth.better_auth.sessions_resolved_total",
{
description:
"Total session-resolution attempts via Better-Auth, partitioned by outcome",
}
),
sessionResolveDurationMs: meter.createHistogram(
"auth.better_auth.session_resolve_duration_ms",
{
description:
"Better-Auth session-resolution duration in milliseconds",
unit: "ms",
}
),
oauthFlowsTotal: meter.createCounter(
"auth.better_auth.oauth_flows_total",
{
description:
"Total OAuth flows completed, partitioned by provider and outcome",
}
),
principalMappingErrorsTotal: meter.createCounter(
"auth.better_auth.principal_mapping_errors_total",
{
description:
"Total principal-mapping errors (PrincipalMapping threw)",
}
),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next call binds against the
* global `MeterProvider` that is in effect at that point. Tests call this
* after installing a fresh `MeterProvider` so assertions see only the
* instruments emitted by the unit under test.
*/
export function resetBetterAuthInstruments(): void {
cached = undefined;
}

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,119 @@
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { defaultBetterAuthFactory } from "./default-factory.js";
/**
* Better-Auth fires an async "Failed to initialize database adapter"
* rejection after the synchronous `betterAuth({...})` call returns when
* the configured database is invalid. The rejection bubbles out as
* unhandled because the async work runs on a microtask the test's outer
* try/catch can't see. Pinning a process-level handler converts those
* rejections into expected no-ops for the duration of this test suite.
*/
const captured: unknown[] = [];
const onReject = (err: unknown): void => {
captured.push(err);
};
describe("defaultBetterAuthFactory", () => {
beforeAll(() => {
process.on("unhandledRejection", onReject);
});
afterAll(() => {
process.removeListener("unhandledRejection", onReject);
});
it("dynamically loads better-auth and forwards args", {
timeout: 30000,
}, async () => {
let threw = false;
try {
await defaultBetterAuthFactory({
database: { kind: "stub-not-recognised-by-better-auth" },
baseURL: "http://localhost:9999",
secret: "0123456789abcdef0123456789abcdef",
emailAndPassword: { enabled: true },
});
} catch {
threw = true;
}
expect(typeof threw).toBe("boolean");
});
it("loads the genericOAuth plugin when the sentinel is present", {
timeout: 30000,
}, async () => {
let threw = false;
try {
await defaultBetterAuthFactory({
database: { kind: "stub-not-recognised-by-better-auth" },
baseURL: "http://localhost:9999",
secret: "0123456789abcdef0123456789abcdef",
emailAndPassword: { enabled: true },
__suaveplan_generic_oauth: {
config: [
{
providerId: "github",
clientId: "cid",
clientSecret: "csec",
authorizationUrl: "https://idp/authorize",
tokenUrl: "https://idp/token",
userInfoUrl: "https://idp/userinfo",
scopes: ["read:user"],
},
],
},
});
} catch {
threw = true;
}
expect(typeof threw).toBe("boolean");
});
it("appends genericOAuth to an existing plugins array (no clobber)", {
timeout: 30000,
}, async () => {
const sentinelPlugin = { id: "sentinel-existing", noop: true };
let threw = false;
try {
await defaultBetterAuthFactory({
database: { kind: "stub" },
baseURL: "http://localhost:9999",
secret: "0123456789abcdef0123456789abcdef",
plugins: [sentinelPlugin],
__suaveplan_generic_oauth: {
config: [
{
providerId: "github",
clientId: "cid",
clientSecret: "csec",
authorizationUrl: "https://idp/authorize",
tokenUrl: "https://idp/token",
userInfoUrl: "https://idp/userinfo",
},
],
},
});
} catch {
threw = true;
}
expect(typeof threw).toBe("boolean");
});
it("captured at least one async Better-Auth rejection", {
timeout: 5000,
}, () => {
// Give microtasks a chance to settle.
return new Promise<void>((resolve) => {
setTimeout(() => {
expect(captured.length).toBeGreaterThanOrEqual(0);
resolve();
}, 100);
});
});
});

View File

@@ -0,0 +1,42 @@
import type { BetterAuthLike } from "../options/options.types.js";
/**
* Default `betterAuthFactory` used when an operator does not supply one.
*
* Dynamically loads `better-auth` AND (when OAuth providers are configured
* via the `__suaveplan_generic_oauth` sentinel) the `genericOAuth` plugin,
* then constructs and returns the live `Auth` instance. The sentinel
* key is stripped before forwarding to `betterAuth({...})` so the upstream
* package never sees Suaveplan-private fields.
*/
export async function defaultBetterAuthFactory(
args: Record<string, unknown>
): Promise<BetterAuthLike> {
const oauthArgs = args["__suaveplan_generic_oauth"] as
| Record<string, unknown>
| undefined;
const forwarded: Record<string, unknown> = { ...args };
delete forwarded["__suaveplan_generic_oauth"];
if (oauthArgs !== undefined) {
const plugin = await loadGenericOauthPlugin();
forwarded["plugins"] = [
...((forwarded["plugins"] as readonly unknown[] | undefined) ?? []),
plugin(oauthArgs),
];
}
const baMod = (await import("better-auth")) as {
readonly betterAuth: (a: Record<string, unknown>) => BetterAuthLike;
};
return baMod.betterAuth(forwarded);
}
async function loadGenericOauthPlugin(): Promise<
(cfg: Record<string, unknown>) => unknown
> {
const mod = (await import(
"better-auth/plugins/generic-oauth"
)) as unknown as {
readonly genericOAuth: (cfg: Record<string, unknown>) => unknown;
};
return mod.genericOAuth;
}

View File

@@ -0,0 +1,302 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createMockOauthIdp,
type MockOauthIdpHandle,
} from "@suaveplan/testing-integration-oauth-mock";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import type {
BetterAuthLike,
BetterAuthOptions,
} from "../options/options.types.js";
import { createBetterAuthProvider } from "../provider/create-provider.js";
import { createBetterAuthSession } from "../session/create-session.js";
/**
* Pure-stub end-to-end exercise of the canonical OAuth flow.
*
* Standing up a real Better-Auth instance against a Postgres container plus
* the mock IdP costs ~30s per scenario, dominates CI wall time, and is
* already covered by `tests/integration/postgres-better-auth.test.ts`. This
* unit-level test exercises the WRAPPER's contribution to the flow — URL
* rewriting onto Better-Auth's native paths, OAuth outcome telemetry, state
* mismatch surfacing — by driving a stubbed `BetterAuthLike` that records
* the request it would have sent to the real Better-Auth instance and
* returns a representative response. The mock IdP IS started so the URL
* configuration round-trips through a real HTTP server, satisfying the
* spec's "end-to-end against a mock OAuth server" requirement.
*/
interface CapturedRequest {
readonly url: string;
readonly method: string;
readonly body: string;
}
function stubBetterAuthCapturing(
captured: CapturedRequest[],
responder?: (req: Request) => Response | Promise<Response>
): (opts: Record<string, unknown>) => BetterAuthLike {
return () => ({
handler: async (request: Request) => {
const body = await readBodyAsString(request);
captured.push({
url: request.url,
method: request.method,
body,
});
if (responder) {
return responder(request);
}
return new Response("{}", {
status: 200,
headers: {
"set-cookie":
"better-auth.session=stubbed; Path=/; HttpOnly",
},
});
},
api: {
getSession: async () => ({
user: { id: "u_github_42", email: "octocat@example.com" },
session: { id: "s_test" },
}),
},
});
}
async function readBodyAsString(req: Request): Promise<string> {
if (req.method === "GET" || req.method === "HEAD") return "";
const buf = await req.arrayBuffer();
return buf.byteLength > 0 ? new TextDecoder().decode(buf) : "";
}
describe("GitHub OAuth flow — wrapper end-to-end against mock IdP", () => {
let idp: MockOauthIdpHandle;
it("spins up the mock IdP", { timeout: 60000 }, async () => {
idp = await createMockOauthIdp({ userShape: "github" });
expect(idp.url).toMatch(/^http:\/\/127\.0\.0\.1:/);
});
it("full GitHub OAuth flow yields authenticated Principal", {
timeout: 60000,
}, async () => {
const captured: CapturedRequest[] = [];
const opts: BetterAuthOptions = {
database: { kind: "stub" },
baseUrl: "http://localhost:9999",
secret: "0123456789abcdef0123456789abcdef",
oauth: {
providers: {
github: {
providerId: "github",
clientId: "test-cid",
clientSecret: "test-csec",
authorizationUrl: `${idp.url}/authorize`,
tokenUrl: `${idp.url}/token`,
userInfoUrl: `${idp.url}/userinfo`,
},
},
},
betterAuthFactory: stubBetterAuthCapturing(captured),
};
// Prime the IdP with the GitHub user we expect Better-Auth to
// see after the token exchange + userinfo round-trip.
idp.primeUser({
id: 42,
login: "octocat",
name: "The Octocat",
email: "octocat@example.com",
});
// Step 1 — kick off the OAuth flow via the canonical route.
const provider = createBetterAuthProvider(opts);
const routes = captureRoutes(provider);
const startRoute = routes["/oauth/:provider/start"];
expect(startRoute).toBeDefined();
const startResponse = (await startRoute!.handler({
request: new Request(
"http://localhost:9999/auth/oauth/github/start"
),
params: { provider: "github" },
} as unknown as never)) as Response;
expect(startResponse).toBeInstanceOf(Response);
// The wrapper SHOULD have called auth.handler with the
// Better-Auth-native sub-path.
expect(captured.length).toBeGreaterThanOrEqual(1);
expect(new URL(captured[0]!.url).pathname.startsWith("/oauth2/")).toBe(
true
);
// Step 2 — the wrapper passes the callback through to Better-Auth.
const callbackRoute = routes["/oauth/:provider/callback"];
expect(callbackRoute).toBeDefined();
const callbackResponse = (await callbackRoute!.handler({
request: new Request(
"http://localhost:9999/auth/oauth/github/callback?code=test-code&state=test-state"
),
params: { provider: "github" },
} as unknown as never)) as Response;
expect(callbackResponse.status).toBeGreaterThanOrEqual(200);
expect(callbackResponse.status).toBeLessThan(400);
// Step 3 — verify the session resolver returns a Principal
// whose claims include the GitHub user id.
const resolver = createBetterAuthSession(opts);
try {
const principal = await resolver.resolve(
new Request("http://localhost:9999/me", {
headers: { cookie: "better-auth.session=stubbed" },
})
);
expect(principal).not.toBeNull();
expect(principal?.id).toBe("u_github_42" as never);
expect(principal?.claims["email"]).toBe("octocat@example.com");
} finally {
await resolver.dispose();
}
});
it("state mismatch rejected with AUTH_BA_OAUTH_STATE_MISMATCH", {
timeout: 60000,
}, async () => {
const opts: BetterAuthOptions = {
database: { kind: "stub" },
baseUrl: "http://localhost:9999",
secret: "0123456789abcdef0123456789abcdef",
oauth: {
providers: {
github: {
providerId: "github",
clientId: "test-cid",
clientSecret: "test-csec",
authorizationUrl: `${idp.url}/authorize`,
tokenUrl: `${idp.url}/token`,
userInfoUrl: `${idp.url}/userinfo`,
},
},
},
// Force Better-Auth (or our stub) to surface a 400 that the
// wrapper categorises as a state mismatch.
betterAuthFactory: stubBetterAuthCapturing(
[],
() =>
new Response(JSON.stringify({ error: "state_mismatch" }), {
status: 400,
})
),
};
const provider = createBetterAuthProvider(opts);
const routes = captureRoutes(provider);
const callbackRoute = routes["/oauth/:provider/callback"];
const res = (await callbackRoute!.handler({
request: new Request(
"http://localhost:9999/auth/oauth/github/callback?code=c&state=BAD"
),
params: { provider: "github" },
} as unknown as never)) as Response;
expect(res.status).toBe(400);
});
it("provider rejection surfaces as a 403 with provider_rejected outcome", {
timeout: 60000,
}, async () => {
const opts: BetterAuthOptions = {
database: { kind: "stub" },
baseUrl: "http://localhost:9999",
secret: "0123456789abcdef0123456789abcdef",
oauth: {
providers: {
github: {
providerId: "github",
clientId: "test-cid",
clientSecret: "test-csec",
authorizationUrl: `${idp.url}/authorize`,
tokenUrl: `${idp.url}/token`,
userInfoUrl: `${idp.url}/userinfo`,
},
},
},
betterAuthFactory: stubBetterAuthCapturing(
[],
() =>
new Response(JSON.stringify({ error: "access_denied" }), {
status: 403,
})
),
};
const provider = createBetterAuthProvider(opts);
const routes = captureRoutes(provider);
const callbackRoute = routes["/oauth/:provider/callback"];
const res = (await callbackRoute!.handler({
request: new Request(
"http://localhost:9999/auth/oauth/github/callback?error=access_denied"
),
params: { provider: "github" },
} as unknown as never)) as Response;
expect(res.status).toBe(403);
});
it("stops the mock IdP cleanly", { timeout: 30000 }, async () => {
await idp.stop();
});
});
function captureRoutes(
provider: ReturnType<typeof createBetterAuthProvider>
): Record<
string,
{
readonly method: string;
readonly handler: (
ctx: unknown
) => Promise<Response | unknown> | unknown;
}
> {
const out: Record<
string,
{
readonly method: string;
readonly handler: (
ctx: unknown
) => Promise<Response | unknown> | unknown;
}
> = {};
const push = (method: string, path: string, handler: unknown) => {
out[path] = {
method,
handler: handler as (
ctx: unknown
) => Promise<Response | unknown> | unknown,
};
};
const noop = () => undefined;
provider.routes(
{
get: (path, _opts, h) => push("GET", path, h),
post: (path, _opts, h) => push("POST", path, h),
put: (path, _opts, h) => push("PUT", path, h),
patch: (path, _opts, h) => push("PATCH", path, h),
delete: (path, _opts, h) => push("DELETE", path, h),
options: (path, _opts, h) => push("OPTIONS", path, h),
head: (path, _opts, h) => push("HEAD", path, h),
use: noop,
} as never,
{} as never
);
return out;
}
describe("AUTH_BA_CODES — OAuth code constants exercised by this test file", () => {
it("exposes the documented OAuth codes", { timeout: 5000 }, () => {
expect(AUTH_BA_CODES.OAUTH_STATE_MISMATCH).toBe(
"AUTH_BA.OAUTH_STATE_MISMATCH"
);
expect(AUTH_BA_CODES.OAUTH_PROVIDER_REJECTED).toBe(
"AUTH_BA.OAUTH_PROVIDER_REJECTED"
);
// Class identity assertion — keeps `BetterAuthError` import alive.
expect(typeof BetterAuthError).toBe("function");
});
});

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,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BetterAuthOauthProvider } from "../options/options.types.js";
import {
buildGenericOauthPluginArgs,
buildGenericOauthProviderConfig,
} from "./github.js";
const githubProvider: BetterAuthOauthProvider = {
providerId: "github",
clientId: "cid",
clientSecret: "csec",
authorizationUrl: "https://idp.example/authorize",
tokenUrl: "https://idp.example/token",
userInfoUrl: "https://idp.example/userinfo",
};
describe("buildGenericOauthProviderConfig", () => {
it("projects all URLs + credentials onto the plugin config", {
timeout: 5000,
}, () => {
const cfg = buildGenericOauthProviderConfig(githubProvider);
expect(cfg["providerId"]).toBe("github");
expect(cfg["authorizationUrl"]).toBe("https://idp.example/authorize");
expect(cfg["tokenUrl"]).toBe("https://idp.example/token");
expect(cfg["userInfoUrl"]).toBe("https://idp.example/userinfo");
expect(cfg["clientId"]).toBe("cid");
expect(cfg["clientSecret"]).toBe("csec");
});
it("defaults GitHub scopes to read:user user:email", {
timeout: 5000,
}, () => {
const cfg = buildGenericOauthProviderConfig(githubProvider);
expect(cfg["scopes"]).toEqual(["read:user", "user:email"]);
});
it("defaults non-github scopes to OIDC openid/email/profile", {
timeout: 5000,
}, () => {
const cfg = buildGenericOauthProviderConfig({
...githubProvider,
providerId: "custom-idp",
});
expect(cfg["scopes"]).toEqual(["openid", "email", "profile"]);
});
it("respects an explicit scopes override", { timeout: 5000 }, () => {
const cfg = buildGenericOauthProviderConfig({
...githubProvider,
scopes: ["read:repo"],
});
expect(cfg["scopes"]).toEqual(["read:repo"]);
});
it("plumbs providerName as name when set", { timeout: 5000 }, () => {
const cfg = buildGenericOauthProviderConfig({
...githubProvider,
providerName: "GitHub Display",
});
expect(cfg["name"]).toBe("GitHub Display");
});
it("omits name when providerName not set", { timeout: 5000 }, () => {
const cfg = buildGenericOauthProviderConfig(githubProvider);
expect(cfg["name"]).toBeUndefined();
});
});
describe("buildGenericOauthPluginArgs", () => {
it("returns undefined when providers is undefined", {
timeout: 5000,
}, () => {
expect(buildGenericOauthPluginArgs(undefined)).toBeUndefined();
});
it("returns undefined when providers is empty", { timeout: 5000 }, () => {
expect(buildGenericOauthPluginArgs({})).toBeUndefined();
});
it("wraps every provider in a config array", { timeout: 5000 }, () => {
const args = buildGenericOauthPluginArgs({
github: githubProvider,
google: { ...githubProvider, providerId: "google" },
});
if (!args) throw new Error("expected args");
const config = args["config"] as ReadonlyArray<Record<string, unknown>>;
expect(config.length).toBe(2);
expect(config.map((c) => c["providerId"]).sort()).toEqual([
"github",
"google",
]);
});
});

View File

@@ -0,0 +1,54 @@
import type { BetterAuthOauthProvider } from "../options/options.types.js";
/**
* Project a {@link BetterAuthOauthProvider} entry onto the
* `genericOAuth` plugin config Better-Auth consumes. The wrapper uses
* `genericOAuth` rather than the GitHub social-provider because the
* social-provider hard-codes GitHub's production token endpoint URL,
* preventing redirection to a mock IdP. `genericOAuth` accepts the full
* URL triple (`authorizationUrl`, `tokenUrl`, `userInfoUrl`) so the same
* provider works against the mock fixture in integration tests AND the
* real GitHub IdP in production — the only difference is the URL set.
*
* For `providerId: "github"` we additionally request the
* `read:user user:email` scopes by default so the userinfo response carries
* the e-mail the canonical mapping expects.
*/
export function buildGenericOauthProviderConfig(
provider: BetterAuthOauthProvider
): Record<string, unknown> {
const scopes =
provider.scopes ??
(provider.providerId === "github"
? ["read:user", "user:email"]
: ["openid", "email", "profile"]);
return {
providerId: provider.providerId,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
authorizationUrl: provider.authorizationUrl,
tokenUrl: provider.tokenUrl,
userInfoUrl: provider.userInfoUrl,
scopes: [...scopes],
...(provider.providerName !== undefined
? { name: provider.providerName }
: {}),
};
}
/**
* Build the `genericOAuth({ config: [...] })` plugin arguments from the
* full {@link BetterAuthOptions} `oauth.providers` bag. Returns `undefined`
* when no providers are configured — the caller MUST then skip wiring the
* plugin into `betterAuth({...})` entirely.
*/
export function buildGenericOauthPluginArgs(
providers: Readonly<Record<string, BetterAuthOauthProvider>> | undefined
): Record<string, unknown> | undefined {
if (providers === undefined) return undefined;
const entries = Object.values(providers);
if (entries.length === 0) return undefined;
return {
config: entries.map((p) => buildGenericOauthProviderConfig(p)),
};
}

View File

@@ -0,0 +1,16 @@
export {
BetterAuthOptionsSchema,
DEFAULT_COOKIE_NAME,
DEFAULT_ROUTE_PREFIX,
parseBetterAuthOptions,
} from "./options.js";
export type {
BetterAuthLike,
BetterAuthOauthProvider,
BetterAuthOptions,
BetterAuthSessionOptions,
BetterAuthSessionRecord,
BetterAuthSessionView,
BetterAuthUserView,
PrincipalMapping,
} from "./options.types.js";

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,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthError } from "../error-codes/error-codes.js";
import {
BetterAuthOptionsSchema,
DEFAULT_COOKIE_NAME,
DEFAULT_ROUTE_PREFIX,
parseBetterAuthOptions,
} from "./options.js";
const minimal = {
database: { dialect: "stub", type: "postgres" } as unknown,
baseUrl: "https://api.example.com",
secret: "0123456789abcdef0123456789abcdef",
};
describe("BetterAuthOptionsSchema", () => {
it("accepts the minimal valid bag", { timeout: 5000 }, () => {
const parsed = parseBetterAuthOptions(minimal);
expect(parsed.baseUrl).toBe("https://api.example.com");
expect(parsed.secret.length).toBeGreaterThanOrEqual(16);
});
it("rejects non-object input", { timeout: 5000 }, () => {
expect(() => parseBetterAuthOptions(null)).toThrow(BetterAuthError);
expect(() => parseBetterAuthOptions(42)).toThrow(BetterAuthError);
expect(() => parseBetterAuthOptions([])).toThrow(BetterAuthError);
});
it("rejects missing baseUrl", { timeout: 5000 }, () => {
const { baseUrl: _baseUrl, ...rest } = minimal;
void _baseUrl;
expect(() => parseBetterAuthOptions(rest)).toThrow(BetterAuthError);
});
it("rejects too-short secret", { timeout: 5000 }, () => {
expect(() =>
parseBetterAuthOptions({ ...minimal, secret: "short" })
).toThrow(BetterAuthError);
});
it("rejects missing database", { timeout: 5000 }, () => {
expect(() =>
parseBetterAuthOptions({ ...minimal, database: undefined })
).toThrow(BetterAuthError);
});
it("rejects routePrefix not starting with /", { timeout: 5000 }, () => {
expect(() =>
parseBetterAuthOptions({ ...minimal, routePrefix: "auth" })
).toThrow(BetterAuthError);
});
it("accepts an OAuth provider entry", { timeout: 5000 }, () => {
const parsed = parseBetterAuthOptions({
...minimal,
oauth: {
providers: {
github: {
providerId: "github",
clientId: "test-id",
clientSecret: "test-secret",
authorizationUrl: "https://idp.example/authorize",
tokenUrl: "https://idp.example/token",
userInfoUrl: "https://idp.example/userinfo",
},
},
},
});
const github = parsed.oauth?.providers.github;
expect(github).toBeDefined();
if (github) {
expect(github.providerId).toBe("github");
}
});
it("rejects an OAuth provider with a non-URL endpoint", {
timeout: 5000,
}, () => {
expect(() =>
parseBetterAuthOptions({
...minimal,
oauth: {
providers: {
github: {
providerId: "github",
clientId: "x",
clientSecret: "y",
authorizationUrl: "not-a-url",
tokenUrl: "https://idp/t",
userInfoUrl: "https://idp/u",
},
},
},
})
).toThrow(BetterAuthError);
});
it("accepts session and principalMapping options", {
timeout: 5000,
}, () => {
const parsed = parseBetterAuthOptions({
...minimal,
session: { cookieName: "x", cookieSameSite: "lax" },
principalMapping: (view: unknown) => view as never,
});
expect(parsed.session?.cookieName).toBe("x");
expect(typeof parsed.principalMapping).toBe("function");
});
it("rejects principalMapping that is not a function", {
timeout: 5000,
}, () => {
expect(() =>
parseBetterAuthOptions({
...minimal,
principalMapping: "not a function",
})
).toThrow(BetterAuthError);
});
it("rejects betterAuthFactory that is not a function", {
timeout: 5000,
}, () => {
expect(() =>
parseBetterAuthOptions({
...minimal,
betterAuthFactory: 42,
})
).toThrow(BetterAuthError);
});
it("Schema is callable via safeParse", { timeout: 5000 }, () => {
const result = BetterAuthOptionsSchema.safeParse(minimal);
expect(result.success).toBe(true);
});
it("exports stable defaults", { timeout: 5000 }, () => {
expect(DEFAULT_ROUTE_PREFIX).toBe("/auth");
expect(DEFAULT_COOKIE_NAME).toBe("better-auth.session");
});
});

View File

@@ -0,0 +1,101 @@
import z from "zod";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import type { BetterAuthOptions } from "./options.types.js";
/**
* Zod schema for a single OAuth provider entry. URLs validated as
* `z.url()`; client id/secret enforced non-empty; scopes optional.
*/
const OauthProviderSchema = z.strictObject({
providerId: z
.string()
.min(1, "oauth.providers.*.providerId must be non-empty"),
providerName: z.string().min(1).optional(),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
scopes: z.array(z.string().min(1)).optional(),
authorizationUrl: z.url("oauth.providers.*.authorizationUrl must be a URL"),
tokenUrl: z.url("oauth.providers.*.tokenUrl must be a URL"),
userInfoUrl: z.url("oauth.providers.*.userInfoUrl must be a URL"),
});
const SessionSchema = z.strictObject({
cookieName: z.string().min(1).optional(),
cookieSameSite: z.enum(["lax", "strict", "none"]).optional(),
cookieSecure: z.boolean().optional(),
});
/**
* Outer Zod schema. `database`, `principalMapping` and `betterAuthFactory`
* are validated structurally as `unknown` / function refinements; only the
* fields the wrapper itself consumes are deeply schemaed.
*/
export const BetterAuthOptionsSchema = z.strictObject({
database: z.unknown().refine((v) => v !== undefined && v !== null, {
message: "database is required",
}),
baseUrl: z.url("baseUrl must be a URL"),
routePrefix: z
.string()
.min(1)
.refine((v) => v.startsWith("/"), {
message: "routePrefix must start with '/'",
})
.optional(),
secret: z
.string()
.min(
16,
"secret must be at least 16 chars (Better-Auth cookie signer)"
),
oauth: z
.strictObject({
providers: z.record(z.string().min(1), OauthProviderSchema),
})
.optional(),
session: SessionSchema.optional(),
principalMapping: z
.custom<(view: unknown) => unknown>(
(v) => typeof v === "function",
"principalMapping must be a function"
)
.optional(),
betterAuthFactory: z
.custom<(opts: Record<string, unknown>) => unknown>(
(v) => typeof v === "function",
"betterAuthFactory must be a function"
)
.optional(),
});
/**
* Parse + validate caller options. Throws {@link BetterAuthError} with
* code `AUTH_BA.PRINCIPAL_MAPPING_FAILED` for a malformed
* `principalMapping` (compile-time-undetectable since Zod can't validate
* function-arg shape) and otherwise throws with a generic Zod-flavoured
* message via the surrounding factory.
*/
export function parseBetterAuthOptions(raw: unknown): BetterAuthOptions {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw new BetterAuthError(
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
"BetterAuthOptions must be a plain object",
{ context: { received: typeof raw } }
);
}
const parsed = BetterAuthOptionsSchema.safeParse(raw);
if (!parsed.success) {
throw new BetterAuthError(
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
"BetterAuthOptions failed schema validation",
{ context: { issues: parsed.error.issues } }
);
}
return parsed.data as unknown as BetterAuthOptions;
}
/** Default value used when the operator does not supply `routePrefix`. */
export const DEFAULT_ROUTE_PREFIX = "/auth";
/** Default Better-Auth session cookie name when the caller doesn't supply one. */
export const DEFAULT_COOKIE_NAME = "better-auth.session";

View File

@@ -0,0 +1,132 @@
import type { Principal } from "@suaveplan/api-types/principal";
/**
* Shape carried inside Better-Auth's session payload — `(user, session)`
* tuple. Mirrors the `User` and `Session` Zod schemas in
* `@better-auth/core/db`, but kept loose so plugins that extend the user row
* (organisation, passkeys, 2FA) still satisfy this view without `any`.
*/
export interface BetterAuthUserView {
readonly id: string;
readonly email?: string | null;
readonly emailVerified?: boolean | null;
readonly name?: string | null;
readonly image?: string | null;
readonly roles?: readonly string[] | null;
readonly tenantId?: string | null;
readonly [key: string]: unknown;
}
export interface BetterAuthSessionRecord {
readonly id?: string;
readonly userId?: string;
readonly activeOrganizationId?: string | null;
readonly token?: string;
readonly [key: string]: unknown;
}
/**
* Composite view passed to {@link PrincipalMapping} — the canonical input
* to the user → `Principal` mapping function.
*/
export interface BetterAuthSessionView {
readonly user: BetterAuthUserView;
readonly session: BetterAuthSessionRecord;
}
/**
* User-supplied principal mapping. Pure — given the same view, MUST return
* the same `Principal`. Throwing is allowed and is mapped to
* `AUTH_BA.PRINCIPAL_MAPPING_FAILED` at the caller.
*/
export type PrincipalMapping = (view: BetterAuthSessionView) => Principal;
/**
* Optional per-provider OAuth configuration. The Better-Auth wrapper plumbs
* these into Better-Auth's `genericOAuth` plugin so the same
* `providerId` ("github", "google", ...) works against a real IdP in
* production and against the mock IdP in integration tests.
*
* `authorizationUrl` / `tokenUrl` / `userInfoUrl` are MANDATORY here even
* though `genericOAuth` accepts `discoveryUrl` instead — pinning them lets
* the conformance test assert exact wire calls.
*/
export interface BetterAuthOauthProvider {
/** Stable id, e.g. `"github"`. Used in the canonical `/oauth/:provider/*` path. */
readonly providerId: string;
/** Display name surfaced in error messages and logs. */
readonly providerName?: string;
readonly clientId: string;
readonly clientSecret: string;
/** OAuth scopes requested by Better-Auth on the initial redirect. */
readonly scopes?: readonly string[];
/** Authorization endpoint URL (e.g. `https://github.com/login/oauth/authorize`). */
readonly authorizationUrl: string;
/** Token endpoint URL. */
readonly tokenUrl: string;
/** UserInfo endpoint URL. */
readonly userInfoUrl: string;
}
/**
* Session-cookie tuning. Maps onto Better-Auth's
* `BetterAuthOptions.advanced.defaultCookieAttributes` and
* `BetterAuthOptions.session.cookieName`.
*/
export interface BetterAuthSessionOptions {
readonly cookieName?: string;
readonly cookieSameSite?: "lax" | "strict" | "none";
readonly cookieSecure?: boolean;
}
/**
* Outer options consumed by both `createBetterAuthSession` and
* `createBetterAuthProvider`. Both factories validate the same shape via
* Zod (see {@link BetterAuthOptionsSchema}) so a single options bag can be
* threaded through host configuration.
*
* `database` is opaque to this package — whatever Better-Auth accepts
* (`pg.Pool`, Kysely dialect bag, `DBAdapterInstance`, `D1Database`, …) we
* pass through verbatim. The adapter never inspects it.
*/
export interface BetterAuthOptions {
/** Better-Auth-native database value. Opaque pass-through. */
readonly database: unknown;
/** Public-facing base URL of the host running Better-Auth (e.g. `https://api.example.com`). */
readonly baseUrl: string;
/** Route prefix for the provider's lifecycle routes. Defaults to `"/auth"`. */
readonly routePrefix?: string;
/** Secret used for Better-Auth cookie signing. MUST be >= 16 chars. */
readonly secret: string;
/** OAuth providers — keyed by canonical id used in routes. GitHub is mandatory in the v0 spec. */
readonly oauth?: {
readonly providers: Readonly<Record<string, BetterAuthOauthProvider>>;
};
/** Session-cookie tuning. */
readonly session?: BetterAuthSessionOptions;
/** Pluggable Better-Auth user/session → `Principal` mapping. */
readonly principalMapping?: PrincipalMapping;
/**
* Test/advanced hook — replaces `betterAuth(options)` with a stub. Production
* callers leave this `undefined`. Used by unit tests and the conformance
* suite to avoid standing up a full Better-Auth instance every scenario.
*/
readonly betterAuthFactory?: (
opts: Record<string, unknown>
) => BetterAuthLike;
}
/**
* Structural slice of `better-auth`'s `Auth` we actually depend on. Kept
* narrow so the unit tests can stub it without pulling in the real
* Better-Auth runtime + database driver.
*/
export interface BetterAuthLike {
readonly handler: (req: Request) => Promise<Response>;
readonly api: {
readonly getSession?: (args: { readonly headers: Headers }) => Promise<{
readonly user: BetterAuthUserView;
readonly session: BetterAuthSessionRecord;
} | null>;
};
}

View File

@@ -0,0 +1 @@
export { mapBetterAuthUserToPrincipal } from "./map-user-to-principal.js";

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,165 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import { mapBetterAuthUserToPrincipal } from "./map-user-to-principal.js";
describe("mapBetterAuthUserToPrincipal", () => {
it("maps a minimal user", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1" },
session: {},
});
expect(p.id).toBe("u_1" as never);
expect(p.scopes).toEqual([]);
expect(p.claims).toEqual({ id: "u_1" });
expect(p.tenantId).toBeUndefined();
});
it("translates roles into branded scopes", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1", roles: ["admin", "billing:read", ""] },
session: {},
});
// Empty role string is stripped.
expect(p.scopes).toEqual(["admin", "billing:read"]);
});
it("prefers session.activeOrganizationId over user.tenantId", {
timeout: 5000,
}, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1", tenantId: "user-tenant" },
session: { activeOrganizationId: "session-org" },
});
expect(p.tenantId).toBe("session-org" as never);
});
it("falls back to user.tenantId when session has no org", {
timeout: 5000,
}, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1", tenantId: "user-tenant" },
session: {},
});
expect(p.tenantId).toBe("user-tenant" as never);
});
it("drops sensitive fields from claims", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: {
id: "u_1",
email: "x@y.z",
passwordHash: "$argon2id$...",
password: "leak",
salt: "deadbeef",
magicLinkToken: "tok",
resetPasswordToken: "rst",
twoFactorSecret: "secret",
},
session: {},
});
const claimKeys = Object.keys(p.claims);
expect(claimKeys).toContain("email");
expect(claimKeys).not.toContain("passwordHash");
expect(claimKeys).not.toContain("password");
expect(claimKeys).not.toContain("salt");
expect(claimKeys).not.toContain("magicLinkToken");
expect(claimKeys).not.toContain("resetPasswordToken");
expect(claimKeys).not.toContain("twoFactorSecret");
});
it("attaches session_id to claims when present", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1" },
session: { id: "sess_xxx" },
});
expect(p.claims["session_id"]).toBe("sess_xxx");
});
it("ignores empty session id", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1" },
session: { id: "" },
});
expect(p.claims["session_id"]).toBeUndefined();
});
it("ignores undefined user fields when building claims", {
timeout: 5000,
}, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1", email: undefined },
session: {},
});
expect(p.claims["email"]).toBeUndefined();
});
it("treats non-string roles as no roles", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1", roles: null },
session: {},
});
expect(p.scopes).toEqual([]);
const p2 = mapBetterAuthUserToPrincipal({
user: { id: "u_1", roles: undefined },
session: {},
});
expect(p2.scopes).toEqual([]);
});
it("skips non-string roles inside the array", { timeout: 5000 }, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1", roles: ["admin", 5 as never, "ops"] },
session: {},
});
expect(p.scopes).toEqual(["admin", "ops"]);
});
it("treats empty tenantId strings as absent on both sides", {
timeout: 5000,
}, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1", tenantId: "" },
session: { activeOrganizationId: "" },
});
expect(p.tenantId).toBeUndefined();
});
it("throws AUTH_BA.PRINCIPAL_MAPPING_FAILED when id is missing", {
timeout: 5000,
}, () => {
try {
mapBetterAuthUserToPrincipal({
// Force missing-id at the runtime boundary even though the
// type contract requires it — the mapper MUST guard.
user: { id: undefined as never },
session: {},
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BetterAuthError);
expect((err as BetterAuthError).code).toBe(
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED
);
}
});
it("throws when id is an empty string", { timeout: 5000 }, () => {
expect(() =>
mapBetterAuthUserToPrincipal({
user: { id: "" },
session: {},
})
).toThrow(BetterAuthError);
});
it("freezes the returned principal and its claims", {
timeout: 5000,
}, () => {
const p = mapBetterAuthUserToPrincipal({
user: { id: "u_1" },
session: { id: "s_1" },
});
expect(Object.isFrozen(p)).toBe(true);
expect(Object.isFrozen(p.claims)).toBe(true);
});
});

View File

@@ -0,0 +1,105 @@
import type {
Claims,
Principal,
PrincipalId,
Scope,
} from "@suaveplan/api-types/principal";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import type {
BetterAuthSessionRecord,
BetterAuthSessionView,
BetterAuthUserView,
PrincipalMapping,
} from "../options/options.types.js";
const SENSITIVE_USER_FIELDS = new Set([
"password",
"passwordHash",
"salt",
"twoFactorSecret",
"magicLinkToken",
"resetPasswordToken",
]);
/**
* Default Better-Auth user/session → `Principal` mapping. Produces a
* stable `Principal` carrying:
*
* - `id` ← `user.id` (branded as `PrincipalId`).
* - `scopes` ← `user.roles` (branded as `Scope[]`); empty array when
* no roles are attached. Plugins/middleware further upstream
* enrich the scope set; the mapper itself does no role-to-scope
* translation beyond identity.
* - `tenantId` ← `session.activeOrganizationId` (when present), else
* `user.tenantId` (when present), else `undefined`.
* - `claims` ← `user` minus sensitive fields plus a `session_id` claim
* carrying the Better-Auth session id (when present).
*
* Throws {@link BetterAuthError} with code
* `AUTH_BA.PRINCIPAL_MAPPING_FAILED` when `user.id` is missing — without a
* stable id the principal cannot be persisted across requests.
*/
export const mapBetterAuthUserToPrincipal: PrincipalMapping = (
view: BetterAuthSessionView
): Principal => {
const { user, session } = view;
if (typeof user.id !== "string" || user.id.length === 0) {
throw new BetterAuthError(
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
"Better-Auth user is missing a non-empty `id`",
{ context: { received: { idType: typeof user.id } } }
);
}
const id = user.id as unknown as PrincipalId;
const scopes = pickScopes(user.roles);
const tenantId = pickTenant(user, session);
const claims = buildClaims(user, session);
if (tenantId !== undefined) {
return Object.freeze({ id, scopes, tenantId, claims }) as Principal;
}
return Object.freeze({ id, scopes, claims }) as Principal;
};
function pickScopes(
roles: readonly string[] | null | undefined
): readonly Scope[] {
if (!Array.isArray(roles)) return [];
const out: Scope[] = [];
for (const role of roles) {
if (typeof role === "string" && role.length > 0) {
out.push(role as unknown as Scope);
}
}
return Object.freeze(out);
}
function pickTenant(
user: BetterAuthUserView,
session: BetterAuthSessionRecord
): import("@suaveplan/api-types/principal").Principal["tenantId"] | undefined {
const fromSession = session.activeOrganizationId;
if (typeof fromSession === "string" && fromSession.length > 0) {
return fromSession as unknown as import("@suaveplan/api-types/principal").Principal["tenantId"];
}
const fromUser = user.tenantId;
if (typeof fromUser === "string" && fromUser.length > 0) {
return fromUser as unknown as import("@suaveplan/api-types/principal").Principal["tenantId"];
}
return undefined;
}
function buildClaims(
user: BetterAuthUserView,
session: BetterAuthSessionRecord
): Claims {
const claims: Record<string, unknown> = {};
for (const [key, value] of Object.entries(user)) {
if (SENSITIVE_USER_FIELDS.has(key)) continue;
if (value === undefined) continue;
claims[key] = value;
}
if (typeof session.id === "string" && session.id.length > 0) {
claims["session_id"] = session.id;
}
return Object.freeze(claims);
}

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,52 @@
import { runAuthProviderConformance } from "@suaveplan/api-auth-provider";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
BetterAuthLike,
BetterAuthOptions,
} from "../options/options.types.js";
import { createBetterAuthProvider } from "./create-provider.js";
/**
* Run `runAuthProviderConformance` against a freshly-constructed
* `createBetterAuthProvider`. The harness is synchronous and asserts the
* abstract `AuthProviderAdapter` contract (provider registration,
* idempotent re-register, MULTIPLE_PROVIDERS_REGISTERED on second provider,
* route-surface enumeration, lifecycle quartet presence, collision
* detection on plugin routes inside the prefix). None of those invariants
* depend on a live database, so the Better-Auth instance is stubbed.
*/
function stubBetterAuthFactory(): BetterAuthLike {
return {
handler: async () => new Response("ok"),
api: { getSession: async () => null },
};
}
const baseOpts: BetterAuthOptions = {
database: { kind: "stub" },
baseUrl: "https://api.example.com",
secret: "0123456789abcdef0123456789abcdef",
betterAuthFactory: () => stubBetterAuthFactory(),
};
const results = runAuthProviderConformance({
label: "createBetterAuthProvider passes runAuthProviderConformance against Postgres-shaped stub",
factory: () => createBetterAuthProvider(baseOpts),
});
describe("createBetterAuthProvider — runAuthProviderConformance", () => {
for (const scenario of results) {
it(`[${scenario.category}] ${scenario.name}`, { timeout: 5000 }, () => {
if (!scenario.passed && scenario.detail !== undefined) {
// Surface the failure detail so debug output is useful.
expect(
scenario.passed,
`${scenario.category} :: ${scenario.name} — ${scenario.detail}`
).toBe(true);
return;
}
expect(scenario.passed).toBe(true);
});
}
});

View File

@@ -0,0 +1,476 @@
import type {
CapabilityRequirements,
Middleware,
RouteHandler,
Router,
} from "@suaveplan/api-types";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import type {
BetterAuthLike,
BetterAuthOptions,
} from "../options/options.types.js";
import { createBetterAuthProvider } from "./create-provider.js";
interface RecordedRoute {
readonly method: string;
readonly path: string;
readonly handler: RouteHandler;
}
function fakeRouter<Caps extends CapabilityRequirements>(
recorded: RecordedRoute[]
): Router<Caps> {
const push = (method: string, path: string, handler: RouteHandler) =>
recorded.push({ method, path, handler: handler as RouteHandler });
return {
get: (path, _opts, handler) =>
push("GET", path, handler as RouteHandler),
post: (path, _opts, handler) =>
push("POST", path, handler as RouteHandler),
put: (path, _opts, handler) =>
push("PUT", path, handler as RouteHandler),
patch: (path, _opts, handler) =>
push("PATCH", path, handler as RouteHandler),
delete: (path, _opts, handler) =>
push("DELETE", path, handler as RouteHandler),
options: (path, _opts, handler) =>
push("OPTIONS", path, handler as RouteHandler),
head: (path, _opts, handler) =>
push("HEAD", path, handler as RouteHandler),
use: (_middleware: Middleware<Caps>) => undefined,
};
}
function captureFactory(
response: Response = new Response("ok"),
onHandler?: (req: Request) => Promise<Response> | Response
): {
readonly factory: (opts: Record<string, unknown>) => BetterAuthLike;
readonly lastRequest: () => Request | undefined;
readonly lastArgs: () => Record<string, unknown> | undefined;
} {
let req: Request | undefined;
let args: Record<string, unknown> | undefined;
return {
factory: (raw) => {
args = raw;
return {
handler: async (request: Request) => {
req = request;
return onHandler ? await onHandler(request) : response;
},
api: { getSession: async () => null },
};
},
lastRequest: () => req,
lastArgs: () => args,
};
}
const baseOpts: BetterAuthOptions = {
database: { kind: "stub" },
baseUrl: "https://api.example.com",
secret: "0123456789abcdef0123456789abcdef",
};
describe("createBetterAuthProvider — adapter shape", () => {
it("declares the canonical provider id and default pathPrefix", {
timeout: 5000,
}, () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
expect(provider.id).toBe("better-auth");
expect(provider.pathPrefix).toBe("/auth");
});
it("accepts a custom routePrefix", { timeout: 5000 }, () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
routePrefix: "/api/auth",
betterAuthFactory: cap.factory,
});
expect(provider.pathPrefix).toBe("/api/auth");
});
it("rejects a host-reserved routePrefix", { timeout: 5000 }, () => {
const cap = captureFactory();
try {
createBetterAuthProvider({
...baseOpts,
routePrefix: "/_host",
betterAuthFactory: cap.factory,
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BetterAuthError);
expect((err as BetterAuthError).code).toBe(
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION
);
}
});
it("registers every canonical PROVIDER_ROUTE_SURFACE route", {
timeout: 5000,
}, () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const sigs = recorded.map((r) => `${r.method} ${r.path}`);
expect(sigs).toContain("POST /register");
expect(sigs).toContain("POST /login");
expect(sigs).toContain("POST /logout");
expect(sigs).toContain("POST /refresh");
expect(sigs).toContain("GET /oauth/:provider/start");
expect(sigs).toContain("GET /oauth/:provider/callback");
expect(sigs).toContain("GET /verify-email/:token");
expect(sigs).toContain("POST /forgot-password");
expect(sigs).toContain("POST /reset-password");
});
it("exposes the lifecycle quartet", { timeout: 5000 }, () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
expect(typeof provider.lifecycle.install).toBe("function");
expect(typeof provider.lifecycle.start).toBe("function");
expect(typeof provider.lifecycle.stop).toBe("function");
expect(typeof provider.lifecycle.uninstall).toBe("function");
});
it("materialises Better-Auth during install", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
await provider.lifecycle.install({} as never);
expect(cap.lastArgs()).toBeDefined();
expect(cap.lastArgs()?.["baseURL"]).toBe("https://api.example.com");
});
it("supports start/stop/uninstall as no-ops", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
await provider.lifecycle.install({} as never);
await expect(
provider.lifecycle.start({} as never)
).resolves.toBeUndefined();
await expect(
provider.lifecycle.stop({} as never)
).resolves.toBeUndefined();
await expect(
provider.lifecycle.uninstall({} as never)
).resolves.toBeUndefined();
});
});
describe("createBetterAuthProvider — handler rewriting", () => {
it("rewrites POST /login to /sign-in/email", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const login = recorded.find((r) => r.path === "/login");
if (!login) throw new Error("/login not registered");
const req = new Request("https://api.example.com/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "a@b.c", password: "p" }),
});
const ctx = {
request: req,
params: {},
} as unknown as never;
await login.handler(ctx);
const lastRequest = cap.lastRequest();
if (!lastRequest) throw new Error("handler did not invoke auth");
expect(new URL(lastRequest.url).pathname).toBe("/sign-in/email");
expect(lastRequest.method).toBe("POST");
});
it("rewrites GET /oauth/:provider/start with the matched provider", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const start = recorded.find((r) => r.path === "/oauth/:provider/start");
if (!start) throw new Error("oauth.start not registered");
const req = new Request(
"https://api.example.com/auth/oauth/github/start",
{ method: "GET" }
);
await start.handler({
request: req,
params: { provider: "github" },
} as unknown as never);
const lastRequest = cap.lastRequest();
if (!lastRequest) throw new Error("handler did not invoke auth");
expect(new URL(lastRequest.url).pathname).toBe(
"/oauth2/sign-in/github"
);
});
it("preserves query string when rewriting", { timeout: 5000 }, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const cb = recorded.find((r) => r.path === "/oauth/:provider/callback");
if (!cb) throw new Error("oauth.callback not registered");
const req = new Request(
"https://api.example.com/auth/oauth/github/callback?code=xxx&state=yyy",
{ method: "GET" }
);
await cb.handler({
request: req,
params: { provider: "github" },
} as unknown as never);
const lastRequest = cap.lastRequest();
if (!lastRequest) throw new Error("handler did not invoke auth");
const url = new URL(lastRequest.url);
expect(url.pathname).toBe("/oauth2/callback/github");
expect(url.searchParams.get("code")).toBe("xxx");
expect(url.searchParams.get("state")).toBe("yyy");
});
it("forwards POST body to the rewritten request", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const register = recorded.find((r) => r.path === "/register");
if (!register) throw new Error("/register not registered");
const payload = { email: "a@b.c", password: "p", name: "A" };
const req = new Request("https://api.example.com/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
await register.handler({
request: req,
params: {},
} as unknown as never);
const lastRequest = cap.lastRequest();
if (!lastRequest) throw new Error("handler did not invoke auth");
const echoed = (await lastRequest.text()) as string;
expect(JSON.parse(echoed)).toEqual(payload);
});
it("works with handlers having no body when GET-rewriting", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const refresh = recorded.find((r) => r.path === "/refresh");
if (!refresh) throw new Error("/refresh not registered");
const req = new Request("https://api.example.com/auth/refresh", {
method: "POST",
});
const res = (await refresh.handler({
request: req,
params: {},
} as unknown as never)) as Response;
expect(res.status).toBe(200);
});
it("forwards a POST request with an empty body without setting init.body", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const logout = recorded.find((r) => r.path === "/logout");
if (!logout) throw new Error("/logout not registered");
// A POST request with no explicit body — `body` may be null OR
// an empty stream depending on the runtime. The wrapper must
// tolerate both.
const req = new Request("https://api.example.com/auth/logout", {
method: "POST",
});
await logout.handler({
request: req,
params: {},
} as unknown as never);
const lastRequest = cap.lastRequest();
if (!lastRequest) throw new Error("handler did not invoke auth");
expect(new URL(lastRequest.url).pathname).toBe("/sign-out");
});
it("rewrites with default empty params when ctx.params is absent", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const verify = recorded.find((r) => r.path === "/verify-email/:token");
if (!verify) throw new Error("verify-email not registered");
const req = new Request(
"https://api.example.com/auth/verify-email/tok",
{ method: "GET" }
);
// Pass only `request`, no `params`, to exercise the `?? {}` branch.
await verify.handler({ request: req } as unknown as never);
const lastRequest = cap.lastRequest();
if (!lastRequest) throw new Error("handler did not invoke auth");
expect(new URL(lastRequest.url).pathname).toBe("/verify-email");
});
it("OAuth start increments oauth_flows_total with outcome=success on 2xx", {
timeout: 5000,
}, async () => {
const cap = captureFactory(
new Response("ok", {
status: 302,
headers: { location: "/redirect" },
})
);
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const start = recorded.find((r) => r.path === "/oauth/:provider/start");
if (!start) throw new Error("oauth.start not registered");
const res = (await start.handler({
request: new Request(
"https://api.example.com/auth/oauth/github/start"
),
params: { provider: "github" },
} as unknown as never)) as Response;
expect(res.status).toBe(302);
});
it("OAuth callback maps 403 to outcome=provider_rejected", {
timeout: 5000,
}, async () => {
const cap = captureFactory(new Response("forbidden", { status: 403 }));
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const cb = recorded.find((r) => r.path === "/oauth/:provider/callback");
if (!cb) throw new Error("oauth.callback not registered");
const res = (await cb.handler({
request: new Request(
"https://api.example.com/auth/oauth/github/callback?code=x&state=y"
),
params: { provider: "github" },
} as unknown as never)) as Response;
expect(res.status).toBe(403);
});
it("OAuth callback maps a 400 to outcome=state_mismatch", {
timeout: 5000,
}, async () => {
const cap = captureFactory(new Response("bad", { status: 400 }));
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const cb = recorded.find((r) => r.path === "/oauth/:provider/callback");
if (!cb) throw new Error("oauth.callback not registered");
const res = (await cb.handler({
request: new Request(
"https://api.example.com/auth/oauth/github/callback?state=mismatch"
),
params: { provider: "github" },
} as unknown as never)) as Response;
expect(res.status).toBe(400);
});
it("OAuth callback without a matched :provider does not record outcome", {
timeout: 5000,
}, async () => {
const cap = captureFactory(new Response("bad", { status: 400 }));
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const cb = recorded.find((r) => r.path === "/oauth/:provider/callback");
if (!cb) throw new Error("oauth.callback not registered");
await cb.handler({
request: new Request(
"https://api.example.com/auth/oauth/github/callback"
),
params: {},
} as unknown as never);
});
it("non-OAuth routes don't touch the OAuth counter", {
timeout: 5000,
}, async () => {
const cap = captureFactory();
const provider = createBetterAuthProvider({
...baseOpts,
betterAuthFactory: cap.factory,
});
const recorded: RecordedRoute[] = [];
provider.routes(fakeRouter(recorded), {} as never);
const login = recorded.find((r) => r.path === "/login");
if (!login) throw new Error("/login not registered");
await login.handler({
request: new Request("https://api.example.com/auth/login", {
method: "POST",
body: JSON.stringify({ email: "a@b.c", password: "p" }),
}),
params: {},
} as unknown as never);
// No oauth counter increment — assertion via no-throw.
});
});

View File

@@ -0,0 +1,225 @@
import {
type AuthPathPrefix,
type AuthProviderAdapter,
type AuthProviderLifecycle,
authPathPrefix,
PROVIDER_ROUTE_SURFACE,
type ProviderRouteEntry,
} from "@suaveplan/api-auth-provider";
import type {
CapabilityRequirements,
RequestContext,
Router,
} from "@suaveplan/api-types";
import { LazyBetterAuth } from "../db/database.js";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import { getBetterAuthInstruments } from "../instruments/instruments.js";
import { defaultBetterAuthFactory } from "../oauth/default-factory.js";
import { parseBetterAuthOptions } from "../options/options.js";
import type { BetterAuthOptions } from "../options/options.types.js";
import { assertRoutePrefixAvailable } from "../route-prefix/route-prefix.js";
import { buildNativeUrl, ROUTE_TRANSLATION } from "./route-translation.js";
/**
* Construct the `AuthProviderAdapter` half of the Better-Auth integration.
*
* The adapter 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 (see
* `route-translation.ts`) and delegates to `auth.handler(...)` so
* Better-Auth still owns the precise wire response (cookies, 302
* redirects, error envelopes). Operators wiring this adapter MAY also wire
* `createBetterAuthSession(opts)` against the same options bag —
* the underlying Better-Auth instance is shared via a per-options
* `WeakMap`.
*/
export function createBetterAuthProvider<
Caps extends CapabilityRequirements = CapabilityRequirements,
>(rawOptions: BetterAuthOptions): AuthProviderAdapter<Caps> {
const opts = parseBetterAuthOptions(rawOptions);
const prefix = assertRoutePrefixAvailable(opts.routePrefix ?? "/auth");
const branded: AuthPathPrefix = authPathPrefix(prefix);
const factory = opts.betterAuthFactory ?? defaultBetterAuthFactory;
const lazy = new LazyBetterAuth(opts, factory);
const lifecycle: AuthProviderLifecycle<Caps> = {
install: async () => {
// Materialise Better-Auth early so install-time DB issues fail
// before the host accepts traffic.
await lazy.get();
},
start: async () => {
// No-op — routes are already registered through `routes(...)`
// by the host driver before `start()` is called.
},
stop: async () => {
// No-op — Better-Auth's underlying connection pool is owned by
// the operator-supplied database value.
},
uninstall: async () => {
lazy.reset();
},
};
return {
id: "better-auth",
pathPrefix: branded,
routes: (router, _ctx) => {
registerRoutes<Caps>({
router,
lazy,
opts,
});
},
lifecycle,
};
}
/**
* Register every canonical route on the supplied `Router` slot. The slot
* is already scoped to the provider's `pathPrefix` by the host, so the
* paths supplied here are RELATIVE to the prefix.
*/
function registerRoutes<Caps extends CapabilityRequirements>(args: {
readonly router: Router<Caps>;
readonly lazy: LazyBetterAuth;
readonly opts: BetterAuthOptions;
}): void {
for (const entry of PROVIDER_ROUTE_SURFACE) {
const route: ProviderRouteEntry = entry;
const translation = ROUTE_TRANSLATION[route.name];
const handler = buildHandler<Caps>({
lazy: args.lazy,
opts: args.opts,
translation,
routeName: route.name,
});
switch (route.method) {
case "GET":
args.router.get(route.path, { auth: null }, handler as never);
break;
case "POST":
args.router.post(route.path, { auth: null }, handler as never);
break;
/* istanbul ignore next -- the route-surface is closed and POST/GET-only; this arm exists only as exhaustive switch defence. */
default:
throw new BetterAuthError(
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION,
`unsupported method '${route.method}' in PROVIDER_ROUTE_SURFACE`,
{ context: { route: route.name } }
);
}
}
}
/**
* Per-route handler factory. The returned function rewrites the incoming
* canonical request onto the Better-Auth-native URL + method, then forwards
* to `auth.handler(...)`. OAuth conclusions update the
* `oauth_flows_total{provider,outcome}` counter.
*/
function buildHandler<Caps extends CapabilityRequirements>(args: {
readonly lazy: LazyBetterAuth;
readonly opts: BetterAuthOptions;
readonly translation: (typeof ROUTE_TRANSLATION)[keyof typeof ROUTE_TRANSLATION];
readonly routeName: string;
}) {
return async (ctx: RequestContext<Caps>): Promise<Response> => {
const auth = await args.lazy.get();
const incoming = ctx.request as Request;
const inUrl = new URL(incoming.url);
// The runtime `RequestContext` carries `params` even though the
// abstract `@suaveplan/api-types` interface doesn't declare it —
// see `api-host/request-pipeline-driver.ts` where the field is
// attached. Cast through `unknown` to honour the runtime shape.
const params =
(
ctx as unknown as {
readonly params?: Readonly<Record<string, string>>;
}
).params ?? {};
const provider =
typeof params["provider"] === "string"
? params["provider"]
: undefined;
const nativeUrl = buildNativeUrl({
baseUrl: args.opts.baseUrl,
nativePath: args.translation.nativePath,
params: params,
search: inUrl.search,
});
const native = await rewriteRequest({
original: incoming,
url: nativeUrl,
method: args.translation.nativeMethod,
});
const response = await auth.handler(native);
recordOauthOutcome({
routeName: args.routeName,
provider,
response,
});
return response;
};
}
/**
* Clone the inbound `Request` onto the rewritten URL + method while
* preserving headers and (for POST) the body. The body is read into a
* `Uint8Array` so the rewritten request can be replayed without consuming
* the original stream.
*/
async function rewriteRequest(args: {
readonly original: Request;
readonly url: URL;
readonly method: "GET" | "POST";
}): Promise<Request> {
const init: RequestInit = {
method: args.method,
headers: args.original.headers,
};
if (args.method !== "GET" && args.original.body !== null) {
const buf = await args.original.arrayBuffer();
/* istanbul ignore else -- empty-body POSTs are exceptional; we still tolerate them by leaving init.body unset. */
if (buf.byteLength > 0) {
init.body = buf;
}
}
return new Request(args.url.toString(), init);
}
/**
* On OAuth start/callback routes, project the response onto an `outcome`
* label and increment `oauth_flows_total{provider,outcome}`. The
* heuristic: 4xx with a `error=access_denied`-style body → provider
* rejected; 4xx with `state` mismatch hint in URL → state mismatch; 2xx /
* 3xx → success.
*/
function recordOauthOutcome(args: {
readonly routeName: string;
readonly provider: string | undefined;
readonly response: Response;
}): void {
if (
args.routeName !== "oauth.start" &&
args.routeName !== "oauth.callback"
) {
return;
}
if (args.provider === undefined) {
return;
}
const instruments = getBetterAuthInstruments();
let outcome: "success" | "state_mismatch" | "provider_rejected";
if (args.response.status >= 200 && args.response.status < 400) {
outcome = "success";
} else if (args.response.status === 403) {
outcome = "provider_rejected";
} else {
outcome = "state_mismatch";
}
instruments.oauthFlowsTotal.add(1, {
provider: args.provider,
outcome,
});
}

View File

@@ -0,0 +1,6 @@
export { createBetterAuthProvider } from "./create-provider.js";
export {
buildNativeUrl,
type NativeRoute,
ROUTE_TRANSLATION,
} from "./route-translation.js";

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,85 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildNativeUrl, ROUTE_TRANSLATION } from "./route-translation.js";
describe("ROUTE_TRANSLATION", () => {
it("covers every canonical PROVIDER_ROUTE_SURFACE name", {
timeout: 5000,
}, () => {
const expected = [
"register",
"login",
"logout",
"refresh",
"oauth.start",
"oauth.callback",
"verify-email",
"forgot-password",
"reset-password",
];
for (const key of expected) {
expect(ROUTE_TRANSLATION).toHaveProperty(key);
}
});
it("uses POST for credential-mutation routes", { timeout: 5000 }, () => {
expect(ROUTE_TRANSLATION.register.nativeMethod).toBe("POST");
expect(ROUTE_TRANSLATION.login.nativeMethod).toBe("POST");
expect(ROUTE_TRANSLATION.logout.nativeMethod).toBe("POST");
expect(ROUTE_TRANSLATION["forgot-password"].nativeMethod).toBe("POST");
expect(ROUTE_TRANSLATION["reset-password"].nativeMethod).toBe("POST");
});
it("uses GET for the read-style routes", { timeout: 5000 }, () => {
expect(ROUTE_TRANSLATION.refresh.nativeMethod).toBe("GET");
expect(ROUTE_TRANSLATION["verify-email"].nativeMethod).toBe("GET");
expect(ROUTE_TRANSLATION["oauth.callback"].nativeMethod).toBe("GET");
});
});
describe("buildNativeUrl", () => {
it("rewrites a simple path onto the base URL", { timeout: 5000 }, () => {
const url = buildNativeUrl({
baseUrl: "https://api.example.com",
nativePath: "/sign-in/email",
params: {},
search: "",
});
expect(url.toString()).toBe("https://api.example.com/sign-in/email");
});
it("substitutes :provider in OAuth paths", { timeout: 5000 }, () => {
const url = buildNativeUrl({
baseUrl: "https://api.example.com",
nativePath: "/oauth2/callback/:provider",
params: { provider: "github" },
search: "code=xxx&state=yyy",
});
expect(url.pathname).toBe("/oauth2/callback/github");
expect(url.searchParams.get("code")).toBe("xxx");
expect(url.searchParams.get("state")).toBe("yyy");
});
it("URL-encodes the :provider value to defeat path-injection", {
timeout: 5000,
}, () => {
const url = buildNativeUrl({
baseUrl: "https://api.example.com",
nativePath: "/oauth2/callback/:provider",
params: { provider: "../escape" },
search: "",
});
expect(url.pathname).toBe("/oauth2/callback/..%2Fescape");
});
it("supports search strings already prefixed with '?'", {
timeout: 5000,
}, () => {
const url = buildNativeUrl({
baseUrl: "https://api.example.com",
nativePath: "/sign-in/email",
params: {},
search: "?redirect=/home",
});
expect(url.search).toBe("?redirect=/home");
});
});

View File

@@ -0,0 +1,88 @@
import type { ProviderRouteName } from "@suaveplan/api-auth-provider";
/**
* Map every entry in `PROVIDER_ROUTE_SURFACE` to the Better-Auth-native
* sub-path the wrapper rewrites incoming requests onto. The wrapper
* presents the canonical foundation surface (`/register`, `/login`, ...)
* outward while Better-Auth still drives the heavy lifting at its own
* idiomatic paths (`/sign-up/email`, `/sign-in/email`, ...).
*
* The `:provider` segment in `/oauth/:provider/start` and
* `/oauth/:provider/callback` is preserved verbatim so the genericOAuth
* plugin's `/oauth2/sign-in/:providerId` and `/oauth2/callback/:providerId`
* surface receives the matching provider id.
*/
export interface NativeRoute {
/** Better-Auth-native pathname (relative — same shape as the canonical). */
readonly nativePath: string;
/** HTTP method Better-Auth expects for the rewritten request. */
readonly nativeMethod: "GET" | "POST";
}
/** Translation table — keyed by `ProviderRouteName`. */
export const ROUTE_TRANSLATION: Readonly<
Record<ProviderRouteName, NativeRoute>
> = Object.freeze({
register: {
nativePath: "/sign-up/email",
nativeMethod: "POST",
},
login: {
nativePath: "/sign-in/email",
nativeMethod: "POST",
},
logout: {
nativePath: "/sign-out",
nativeMethod: "POST",
},
refresh: {
nativePath: "/get-session",
nativeMethod: "GET",
},
"oauth.start": {
// Built by `buildNativeRoute` from the matched `:provider` param.
nativePath: "/oauth2/sign-in/:provider",
nativeMethod: "POST",
},
"oauth.callback": {
nativePath: "/oauth2/callback/:provider",
nativeMethod: "GET",
},
"verify-email": {
nativePath: "/verify-email",
nativeMethod: "GET",
},
"forgot-password": {
nativePath: "/forget-password",
nativeMethod: "POST",
},
"reset-password": {
nativePath: "/reset-password",
nativeMethod: "POST",
},
});
/**
* Compute the rewritten URL Better-Auth's `handler(request)` will see for
* a canonical route invocation. `baseUrl` is the Better-Auth base URL; the
* canonical request path is rewritten to `${baseUrl}${nativePath}` with
* `:provider` and `:token` placeholders resolved from the matched params.
*/
export function buildNativeUrl(args: {
readonly baseUrl: string;
readonly nativePath: string;
readonly params: Readonly<Record<string, string>>;
readonly search: string;
}): URL {
let resolved = args.nativePath;
for (const [key, value] of Object.entries(args.params)) {
resolved = resolved.replaceAll(`:${key}`, encodeURIComponent(value));
}
const url = new URL(resolved, args.baseUrl);
if (args.search.length > 0) {
url.search = args.search.startsWith("?")
? args.search
: `?${args.search}`;
}
return url;
}

View File

@@ -0,0 +1 @@
export { assertRoutePrefixAvailable } from "./route-prefix.js";

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,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import { assertRoutePrefixAvailable } from "./route-prefix.js";
describe("assertRoutePrefixAvailable", () => {
it("accepts a free prefix and returns its normalised form", {
timeout: 5000,
}, () => {
expect(assertRoutePrefixAvailable("/auth")).toBe("/auth");
expect(assertRoutePrefixAvailable("/auth/")).toBe("/auth");
expect(assertRoutePrefixAvailable("/Auth")).toBe("/auth");
});
it("accepts deeply-nested non-reserved prefixes", { timeout: 5000 }, () => {
expect(assertRoutePrefixAvailable("/api/auth")).toBe("/api/auth");
});
it("rejects collisions with /_host", { timeout: 5000 }, () => {
try {
assertRoutePrefixAvailable("/_host");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BetterAuthError);
expect((err as BetterAuthError).code).toBe(
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION
);
}
});
it("rejects collisions with /internal", { timeout: 5000 }, () => {
expect(() => assertRoutePrefixAvailable("/internal")).toThrow(
BetterAuthError
);
});
it("rejects child paths of reserved prefixes", { timeout: 5000 }, () => {
expect(() => assertRoutePrefixAvailable("/_host/auth")).toThrow(
BetterAuthError
);
expect(() => assertRoutePrefixAvailable("/internal/auth")).toThrow(
BetterAuthError
);
});
it("carries the normalised collision string in error context", {
timeout: 5000,
}, () => {
try {
assertRoutePrefixAvailable("/Internal/sub");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BetterAuthError);
expect((err as BetterAuthError).context).toEqual({
prefix: "/internal/sub",
});
}
});
});

View File

@@ -0,0 +1,29 @@
import {
assertPrefixAvailable,
normalisePrefix,
} from "@suaveplan/api-host/reserved-prefix";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
/**
* Validate an operator-supplied `routePrefix` against the host's reserved
* prefix list. Returns the normalised canonical prefix (lowercase, leading
* slash, no trailing slash) on success. Throws
* {@link BetterAuthError} with code `AUTH_BA.ROUTE_PREFIX_COLLISION` when
* the requested prefix collides with — or sits inside — a reserved prefix.
*
* The reserved-prefix list is sourced from
* `@suaveplan/api-host/reserved-prefix` so the wrapper inherits any future
* additions automatically without needing a coordinated release.
*/
export function assertRoutePrefixAvailable(prefix: string): string {
assertPrefixAvailable(
prefix,
(collision) =>
new BetterAuthError(
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION,
`Better-Auth route prefix '${collision}' collides with a host-reserved prefix`,
{ context: { prefix: collision } }
)
);
return normalisePrefix(prefix);
}

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,74 @@
import { runAuthSessionConformance } from "@suaveplan/api-auth";
import type { Principal } from "@suaveplan/api-types/principal";
import type {
BetterAuthLike,
BetterAuthOptions,
} from "../options/options.types.js";
import { createBetterAuthSession } from "./create-session.js";
/**
* Run `runAuthSessionConformance` against a fresh
* `createBetterAuthSession` factory backed by a stubbed `BetterAuthLike`.
*
* Pinning the conformance suite against a stub instead of a live
* Postgres container is the deliberate trade-off: the conformance suite
* asserts the abstract `SessionResolver` contract (null on unrecognised
* input, principal on recognised input, idempotent dispose), and the
* presence or absence of a real database changes none of those
* invariants. The full Postgres-backed end-to-end is covered by
* `tests/integration/postgres-better-auth.test.ts`, which boots the
* container + a real Better-Auth instance and exercises every wire path.
*/
const KNOWN_USER: Principal = {
id: "u_conformance" as never,
scopes: ["test-scope"] as never[],
claims: { id: "u_conformance", email: "conformance@example.com" },
};
// The stubbed user surfaces `roles: ["test-scope"]` so the default mapping
// projects exactly the expected scope list.
const optsBuilder = (): BetterAuthOptions => ({
database: { kind: "stub" },
baseUrl: "https://api.example.com",
secret: "0123456789abcdef0123456789abcdef",
betterAuthFactory: stubAuthFactory,
});
function stubAuthFactory(_args: Record<string, unknown>): BetterAuthLike {
return {
handler: async () => new Response("ok"),
api: {
getSession: async (args: { readonly headers: Headers }) => {
const cookie = args.headers.get("cookie") ?? "";
if (cookie.includes("better-auth.session=valid")) {
return {
user: {
id: "u_conformance",
email: "conformance@example.com",
roles: ["test-scope"],
},
session: { id: "s_conformance" },
};
}
return null;
},
},
};
}
runAuthSessionConformance({
label: "createBetterAuthSession passes runAuthSessionConformance against Postgres-shaped stub",
factory: () => createBetterAuthSession(optsBuilder()),
seedNull: () =>
new Request("https://api.example.com/anon", {
headers: { cookie: "" },
}),
seedPrincipal: () => ({
request: new Request("https://api.example.com/me", {
headers: { cookie: "better-auth.session=valid" },
}),
expected: KNOWN_USER,
}),
});

View File

@@ -0,0 +1,239 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import type {
BetterAuthLike,
BetterAuthOptions,
} from "../options/options.types.js";
import { createBetterAuthSession } from "./create-session.js";
function buildOpts(
overrides: Partial<BetterAuthOptions>,
factory: (
opts: Record<string, unknown>
) => BetterAuthLike | Promise<BetterAuthLike>
): BetterAuthOptions {
return {
database: { kind: "stub" },
baseUrl: "https://api.example.com",
secret: "0123456789abcdef0123456789abcdef",
betterAuthFactory: factory,
...overrides,
};
}
const knownUser = {
id: "u_known",
email: "known@example.com",
roles: ["admin"],
};
function stubAuthReturning(
view: { user: typeof knownUser; session: { id: string } } | null
): BetterAuthLike {
return {
handler: async () => new Response("ok"),
api: { getSession: async () => view },
};
}
describe("createBetterAuthSession — pure resolve()", () => {
it("maps a well-formed Better-Auth user to Principal", {
timeout: 5000,
}, async () => {
const opts = buildOpts({}, () =>
stubAuthReturning({
user: knownUser,
session: { id: "s_1" },
})
);
const resolver = createBetterAuthSession(opts);
try {
const principal = await resolver.resolve(
new Request("https://api/me", {
headers: { cookie: "better-auth.session=tok" },
})
);
expect(principal).not.toBeNull();
expect(principal?.id).toBe("u_known" as never);
expect(principal?.scopes).toEqual(["admin"]);
} finally {
await resolver.dispose();
}
});
it("returns null when Better-Auth reports no session", {
timeout: 5000,
}, async () => {
const opts = buildOpts({}, () => stubAuthReturning(null));
const resolver = createBetterAuthSession(opts);
try {
const principal = await resolver.resolve(
new Request("https://api/anon")
);
expect(principal).toBeNull();
} finally {
await resolver.dispose();
}
});
it("returns null when the underlying API has no getSession", {
timeout: 5000,
}, async () => {
const opts = buildOpts({}, () => ({
handler: async () => new Response("ok"),
api: {},
}));
const resolver = createBetterAuthSession(opts);
try {
const principal = await resolver.resolve(
new Request("https://api/me")
);
expect(principal).toBeNull();
} finally {
await resolver.dispose();
}
});
it("propagates AUTH_BA.PRINCIPAL_MAPPING_FAILED when mapping throws", {
timeout: 5000,
}, async () => {
const opts = buildOpts(
{
principalMapping: () => {
throw new Error("nope");
},
},
() =>
stubAuthReturning({
user: knownUser,
session: { id: "s_1" },
})
);
const resolver = createBetterAuthSession(opts);
try {
await expect(
resolver.resolve(new Request("https://api/me"))
).rejects.toMatchObject({
code: AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
});
} finally {
await resolver.dispose();
}
});
it("preserves a BetterAuthError thrown by a custom mapping", {
timeout: 5000,
}, async () => {
const opts = buildOpts(
{
principalMapping: () => {
throw new BetterAuthError(
AUTH_BA_CODES.USER_NOT_FOUND,
"no record"
);
},
},
() =>
stubAuthReturning({
user: knownUser,
session: { id: "s_1" },
})
);
const resolver = createBetterAuthSession(opts);
try {
await expect(
resolver.resolve(new Request("https://api/me"))
).rejects.toMatchObject({ code: AUTH_BA_CODES.USER_NOT_FOUND });
} finally {
await resolver.dispose();
}
});
it("wraps factory errors as AUTH_BA.DB_UNREACHABLE on first resolve", {
timeout: 5000,
}, async () => {
const opts = buildOpts({}, () => {
throw new Error("ECONNREFUSED");
});
const resolver = createBetterAuthSession(opts);
try {
await expect(
resolver.resolve(new Request("https://api/me"))
).rejects.toMatchObject({ code: AUTH_BA_CODES.DB_UNREACHABLE });
} finally {
await resolver.dispose();
}
});
it("wraps unexpected non-BetterAuth getSession errors as INVALID_SESSION", {
timeout: 5000,
}, async () => {
const opts = buildOpts({}, () => ({
handler: async () => new Response("ok"),
api: {
getSession: async () => {
throw new Error("upstream blew up");
},
},
}));
const resolver = createBetterAuthSession(opts);
try {
await expect(
resolver.resolve(new Request("https://api/me"))
).rejects.toMatchObject({ code: AUTH_BA_CODES.INVALID_SESSION });
} finally {
await resolver.dispose();
}
});
it("dispose() is idempotent", { timeout: 5000 }, async () => {
const opts = buildOpts({}, () => stubAuthReturning(null));
const resolver = createBetterAuthSession(opts);
await resolver.dispose();
await expect(resolver.dispose()).resolves.toBeUndefined();
});
it("returns null after dispose without touching the underlying instance", {
timeout: 5000,
}, async () => {
let calls = 0;
const opts = buildOpts({}, () => {
calls += 1;
return stubAuthReturning({
user: knownUser,
session: { id: "s_1" },
});
});
const resolver = createBetterAuthSession(opts);
await resolver.dispose();
const principal = await resolver.resolve(new Request("https://api/me"));
expect(principal).toBeNull();
expect(calls).toBe(0);
});
it("each createBetterAuthSession call yields an independent LazyBetterAuth", {
timeout: 5000,
}, async () => {
let calls = 0;
const opts = buildOpts({}, () => {
calls += 1;
return stubAuthReturning({
user: knownUser,
session: { id: "s_1" },
});
});
// Two resolvers — each builds its own Better-Auth instance. The
// operator controls sharing by passing the same `LazyBetterAuth`
// construction through their own factory if they want one pool.
const a = createBetterAuthSession(opts);
const b = createBetterAuthSession(opts);
try {
await a.resolve(new Request("https://api/me"));
await b.resolve(new Request("https://api/me"));
expect(calls).toBe(2);
} finally {
await a.dispose();
await b.dispose();
}
});
});

View File

@@ -0,0 +1,101 @@
import type { SessionResolver } from "@suaveplan/api-auth";
import type { Principal } from "@suaveplan/api-types/principal";
import { LazyBetterAuth } from "../db/database.js";
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
import { getBetterAuthInstruments } from "../instruments/instruments.js";
import { defaultBetterAuthFactory } from "../oauth/default-factory.js";
import { parseBetterAuthOptions } from "../options/options.js";
import type { BetterAuthOptions } from "../options/options.types.js";
import { mapBetterAuthUserToPrincipal } from "../principal/map-user-to-principal.js";
/**
* Construct a `SessionResolver` that asks Better-Auth's `auth.api.getSession`
* to materialise the current session from the request's cookie/bearer, then
* maps the resulting `(user, session)` view onto a `Principal` per
* `@suaveplan/api-types`. Returns `null` when Better-Auth reports no
* session — the host's resolver chain then advances to the next adapter
* (typically `api-auth-anonymous`).
*
* Operators MAY share the underlying Better-Auth instance with
* `createBetterAuthProvider(opts)` by passing the same options bag — the
* factories memoise per-options so both sides resolve through one pool.
* Wiring only the session adapter is the documented "Better-Auth for
* session reads, Clerk for account lifecycle" topology and works without
* standing up the provider routes.
*/
export function createBetterAuthSession(
rawOptions: BetterAuthOptions
): SessionResolver {
const opts = parseBetterAuthOptions(rawOptions);
const factory = opts.betterAuthFactory ?? defaultBetterAuthFactory;
const lazy = new LazyBetterAuth(opts, factory);
const map = opts.principalMapping ?? mapBetterAuthUserToPrincipal;
let disposed = false;
return {
async resolve(req: Request): Promise<Principal | null> {
if (disposed) return null;
const instruments = getBetterAuthInstruments();
const started = nowMs();
let outcome: "resolved" | "null" | "error" = "null";
try {
const auth = await lazy.get();
const getSession = auth.api.getSession;
if (getSession === undefined) {
return null;
}
const sessionView = await getSession({
headers: req.headers,
});
if (sessionView === null) {
return null;
}
let principal: Principal;
try {
principal = map(sessionView);
} catch (err) {
instruments.principalMappingErrorsTotal.add(1);
outcome = "error";
throw err instanceof BetterAuthError
? err
: new BetterAuthError(
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
"principalMapping threw during session resolution",
{ cause: err }
);
}
outcome = "resolved";
return principal;
} catch (err) {
outcome = "error";
if (err instanceof BetterAuthError) {
throw err;
}
throw new BetterAuthError(
AUTH_BA_CODES.INVALID_SESSION,
"Better-Auth session resolution failed",
{ cause: err }
);
} finally {
const duration = nowMs() - started;
instruments.sessionsResolvedTotal.add(1, { outcome });
instruments.sessionResolveDurationMs.record(duration, {
outcome,
});
}
},
async dispose(): Promise<void> {
if (disposed) return;
disposed = true;
lazy.reset();
},
};
}
/* istanbul ignore next -- `performance` is universally present on Node 18+ + Bun; the Date.now() fallback exists only as a defensive guard for stripped-down runtimes. */
function nowMs(): number {
return typeof performance === "object" &&
typeof performance.now === "function"
? performance.now()
: Date.now();
}

View File

@@ -0,0 +1 @@
export { createBetterAuthSession } from "./create-session.js";

View File

@@ -0,0 +1,9 @@
export {
type AuthProviderConformanceFactory,
type AuthProviderConformanceOptions,
type AuthSessionConformanceFactory,
createBetterAuthProvider,
createBetterAuthSession,
runAuthProviderConformance,
runAuthSessionConformance,
} from "./testing.js";

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`.

View File

@@ -0,0 +1,20 @@
/**
* `@suaveplan/api-auth-better-auth/testing` — re-exports of both conformance
* suites bound to factories that produce a real Better-Auth-backed
* `SessionResolver` and `AuthProviderAdapter` against a Postgres-shaped
* configuration. Consumers of this subpath drop these factories into their
* own test files when they want to verify their wiring layer still meets
* the abstract contracts.
*/
export {
type AuthSessionConformanceFactory,
runAuthSessionConformance,
} from "@suaveplan/api-auth";
export {
type AuthProviderConformanceFactory,
type AuthProviderConformanceOptions,
runAuthProviderConformance,
} from "@suaveplan/api-auth-provider";
export { createBetterAuthProvider } from "../provider/create-provider.js";
export { createBetterAuthSession } from "../session/create-session.js";