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