@nifrajs/better-auth 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nifra Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @nifrajs/better-auth
2
+
3
+ Mount [better-auth](https://better-auth.com) into a [nifra](https://github.com/nifra) app: one
4
+ `app.use(betterAuth(auth))` wires every better-auth endpoint (sign-in/up/out, OAuth callbacks,
5
+ session, 2FA, magic links, …) under `/api/auth/*`, plus typed `getSession` / `requireSession` guards
6
+ for reading and protecting routes.
7
+
8
+ This package has **no runtime dependency on better-auth** — it consumes your `auth` object
9
+ structurally, so your better-auth types flow through by inference and your tests need no DB.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ bun add @nifrajs/better-auth better-auth
15
+ ```
16
+
17
+ ## Setup
18
+
19
+ Configure better-auth as usual (database, providers, …), then mount it:
20
+
21
+ ```ts
22
+ // auth.ts
23
+ import { betterAuth as createBetterAuth } from "better-auth"
24
+ export const auth = createBetterAuth({
25
+ database: /* your adapter */,
26
+ emailAndPassword: { enabled: true },
27
+ // basePath defaults to "/api/auth"
28
+ })
29
+ ```
30
+
31
+ ```ts
32
+ // server.ts
33
+ import { server } from "@nifrajs/core"
34
+ import { betterAuth } from "@nifrajs/better-auth"
35
+ import { auth } from "./auth"
36
+
37
+ export const app = server()
38
+ .use(betterAuth(auth)) // serves GET + POST /api/auth/*
39
+ .get("/", () => ({ ok: true }))
40
+ ```
41
+
42
+ `betterAuth(auth, { basePath })` overrides the mount path; otherwise it uses `auth.options.basePath`,
43
+ then `/api/auth`. The plugin is **idempotent** (named `"better-auth"`): applying it twice mounts once.
44
+
45
+ ## Read the session
46
+
47
+ `getSession(auth, request)` is a typed wrapper over `auth.api.getSession`. Pass the raw `Request` so it
48
+ works in both core handlers (`c.req`) and `@nifrajs/web` loaders/actions (`request`):
49
+
50
+ ```ts
51
+ import { getSession } from "@nifrajs/better-auth"
52
+
53
+ app.get("/me", async (c) => {
54
+ const session = await getSession(auth, c.req) // { user, session } | null — fully typed
55
+ return session ? { email: session.user.email } : { email: null }
56
+ })
57
+ ```
58
+
59
+ ## Protect a route
60
+
61
+ `requireSession(auth, request, options?)` returns the non-null session or **throws a `Response`** —
62
+ nifra returns a thrown `Response` as-is, short-circuiting the handler:
63
+
64
+ ```ts
65
+ import { requireSession } from "@nifrajs/better-auth"
66
+
67
+ // 401 JSON { ok: false, error: "unauthorized" } when signed out:
68
+ app.get("/account", async (c) => {
69
+ const { user } = await requireSession(auth, c.req)
70
+ return { id: user.id }
71
+ })
72
+
73
+ // 302 to a login page instead (same-origin path required):
74
+ export const loader = async ({ request }) => {
75
+ const { user } = await requireSession(auth, request, { redirectTo: "/login" })
76
+ return { user }
77
+ }
78
+ ```
79
+
80
+ ## API
81
+
82
+ | Export | Description |
83
+ | --- | --- |
84
+ | `betterAuth(auth, options?)` | Plugin that mounts better-auth's handler at `${basePath}/*` for GET + POST. |
85
+ | `getSession(auth, request)` | `Promise<SessionOf<A> \| null>` — typed wrapper over `auth.api.getSession`. |
86
+ | `requireSession(auth, request, options?)` | Returns the session or throws a `Response` (302 `redirectTo` / 401). |
87
+ | `SessionOf<A>` | The non-null session payload type inferred from your `auth`. |
88
+ | `BetterAuthLike` | The structural contract a better-auth instance satisfies. |
89
+
90
+ ## License
91
+
92
+ MIT
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
3
+ * Declared structurally rather than imported, so `@nifrajs/better-auth` has **no runtime dependency** on
4
+ * better-auth: you pass your own `auth` object and its concrete types flow through
5
+ * {@link getSession} / {@link requireSession} via inference.
6
+ */
7
+ export interface BetterAuthLike {
8
+ /** better-auth's catch-all handler — serves every request under `basePath`. */
9
+ readonly handler: (request: Request) => Response | Promise<Response>;
10
+ readonly api: {
11
+ /** Resolve the session from request headers (cookie or bearer). Returns a nullable payload. */
12
+ readonly getSession: (context: {
13
+ readonly headers: Headers;
14
+ }) => Promise<unknown>;
15
+ };
16
+ /** better-auth's resolved options; `basePath` (when set) defaults the mount path. */
17
+ readonly options?: {
18
+ readonly basePath?: string;
19
+ };
20
+ }
21
+ /**
22
+ * The non-null session payload of a concrete better-auth instance `A`, inferred from its
23
+ * `api.getSession` return type (typically `{ user: User; session: Session }`).
24
+ */
25
+ export type SessionOf<A extends BetterAuthLike> = NonNullable<Awaited<ReturnType<A["api"]["getSession"]>>>;
26
+ export interface BetterAuthOptions {
27
+ /** Mount path for better-auth's routes. Defaults to `auth.options.basePath`, then `"/api/auth"`. */
28
+ readonly basePath?: string;
29
+ }
30
+ /**
31
+ * Mount a better-auth instance into a nifra app: registers its handler at `${basePath}/*`
32
+ * (default `/api/auth/*`) for `GET` + `POST`, so every better-auth endpoint — sign-in/up/out, OAuth
33
+ * callbacks, session, 2FA, magic links, … — is served by your nifra server.
34
+ *
35
+ * ```ts
36
+ * import { betterAuth, requireSession } from "@nifrajs/better-auth"
37
+ * import { auth } from "./auth" // your configured better-auth instance
38
+ *
39
+ * const app = server().use(betterAuth(auth)) // wires /api/auth/*
40
+ * .get("/me", async (c) => (await requireSession(auth, c.req)).user)
41
+ * ```
42
+ *
43
+ * Idempotent (named `"better-auth"` — applying twice mounts once). Read the session with
44
+ * {@link getSession} / {@link requireSession}, which infer your better-auth types.
45
+ */
46
+ export declare function betterAuth(auth: BetterAuthLike, options?: BetterAuthOptions): import("@nifrajs/core").IdentityPlugin;
47
+ /**
48
+ * Resolve the better-auth session for a request — a thin, typed wrapper over `auth.api.getSession`.
49
+ * Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
50
+ * (`c.req`) and web loaders/actions (`request`).
51
+ *
52
+ * ```ts
53
+ * const session = await getSession(auth, c.req) // typed: { user, session } | null
54
+ * if (session) c.set.headers["x-user"] = session.user.id
55
+ * ```
56
+ */
57
+ export declare function getSession<A extends BetterAuthLike>(auth: A, request: Request): Promise<SessionOf<A> | null>;
58
+ /**
59
+ * What {@link requireSession} does on a missing session: `302` to `redirectTo` (a same-origin path),
60
+ * or — when omitted — a `401` JSON (`{ ok: false, error: "unauthorized" }`). Mirrors `@nifrajs/auth` guards.
61
+ */
62
+ export interface RequireSessionOptions {
63
+ readonly redirectTo?: string;
64
+ }
65
+ /**
66
+ * Require an authenticated better-auth session at the top of a protected handler/loader/action.
67
+ * Returns the (non-null) session when present; otherwise **throws a `Response`** (302/401) — nifra
68
+ * returns a thrown `Response` as-is, short-circuiting the rest of the handler.
69
+ *
70
+ * ```ts
71
+ * const { user } = await requireSession(auth, c.req, { redirectTo: "/login" })
72
+ * ```
73
+ */
74
+ export declare function requireSession<A extends BetterAuthLike>(auth: A, request: Request, options?: RequireSessionOptions): Promise<SessionOf<A>>;
75
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACpE,QAAQ,CAAC,GAAG,EAAE;QACZ,+FAA+F;QAC/F,QAAQ,CAAC,UAAU,EAAE,CAAC,OAAO,EAAE;YAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;SAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;KAClF,CAAA;IACD,qFAAqF;IACrF,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAClD;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,cAAc,IAAI,WAAW,CAC3D,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAC5C,CAAA;AAED,MAAM,WAAW,iBAAiB;IAChC,oGAAoG;IACpG,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAKD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,GAAE,iBAAsB,0CAiB/E;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS,cAAc,EACjD,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAI9B;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAeD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,CAAC,SAAS,cAAc,EAC3D,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAIvB"}
package/dist/index.js ADDED
@@ -0,0 +1,77 @@
1
+ import { defineIdentityPlugin } from "@nifrajs/core";
2
+ // better-auth dispatches only GET (session, OAuth/email callbacks) and POST (sign-in/up/out, etc.).
3
+ const HANDLED_METHODS = ["GET", "POST"];
4
+ /**
5
+ * Mount a better-auth instance into a nifra app: registers its handler at `${basePath}/*`
6
+ * (default `/api/auth/*`) for `GET` + `POST`, so every better-auth endpoint — sign-in/up/out, OAuth
7
+ * callbacks, session, 2FA, magic links, … — is served by your nifra server.
8
+ *
9
+ * ```ts
10
+ * import { betterAuth, requireSession } from "@nifrajs/better-auth"
11
+ * import { auth } from "./auth" // your configured better-auth instance
12
+ *
13
+ * const app = server().use(betterAuth(auth)) // wires /api/auth/*
14
+ * .get("/me", async (c) => (await requireSession(auth, c.req)).user)
15
+ * ```
16
+ *
17
+ * Idempotent (named `"better-auth"` — applying twice mounts once). Read the session with
18
+ * {@link getSession} / {@link requireSession}, which infer your better-auth types.
19
+ */
20
+ export function betterAuth(auth, options = {}) {
21
+ const base = options.basePath ?? auth.options?.basePath ?? "/api/auth";
22
+ const pattern = `${base.replace(/\/+$/, "")}/*`; // strip trailing slash(es), then wildcard the subtree
23
+ // A type-IDENTITY plugin (see defineIdentityPlugin): mounting the auth handler must NOT change the
24
+ // route registry's type. A plain `definePlugin((app) => app)` would infer `app: Server<any, any>`, so
25
+ // `use`'s result — and the entire typed client derived from it — collapsed to `any`. This was the #1
26
+ // reported anti-drift bug: routes declared after `.use(betterAuth(...))` silently lost their types.
27
+ return defineIdentityPlugin("better-auth", (app) => {
28
+ for (const method of HANDLED_METHODS) {
29
+ // `register`'s handler is typed `(context: never) => unknown`; the framework invokes it with the
30
+ // real Context, so reading `c.req` is sound. Returning better-auth's Response passes through as-is.
31
+ app.register(method, pattern, undefined, (c) => auth.handler(c.req));
32
+ }
33
+ return app;
34
+ });
35
+ }
36
+ /**
37
+ * Resolve the better-auth session for a request — a thin, typed wrapper over `auth.api.getSession`.
38
+ * Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
39
+ * (`c.req`) and web loaders/actions (`request`).
40
+ *
41
+ * ```ts
42
+ * const session = await getSession(auth, c.req) // typed: { user, session } | null
43
+ * if (session) c.set.headers["x-user"] = session.user.id
44
+ * ```
45
+ */
46
+ export function getSession(auth, request) {
47
+ // `auth` is the concrete `A`, so `getSession`'s real return type is recovered by `SessionOf<A>`;
48
+ // the cast bridges the erased `Promise<unknown>` view inside this generic body.
49
+ return auth.api.getSession({ headers: request.headers });
50
+ }
51
+ const rejection = (options) => {
52
+ const to = options.redirectTo;
53
+ if (to === undefined)
54
+ return Response.json({ ok: false, error: "unauthorized" }, { status: 401 });
55
+ // Same-origin guard (mirrors @nifrajs/web `redirect` + @nifrajs/auth guards): a single leading "/", never
56
+ // "//host" or an absolute URL. `redirectTo` is dev-authored, so a bad value is a config bug — fail loud.
57
+ if (!to.startsWith("/") || to.startsWith("//")) {
58
+ throw new Error(`[nifra/better-auth] requireSession redirectTo must be a same-origin path beginning with "/" (got ${JSON.stringify(to)})`);
59
+ }
60
+ return new Response(null, { status: 302, headers: { location: to } });
61
+ };
62
+ /**
63
+ * Require an authenticated better-auth session at the top of a protected handler/loader/action.
64
+ * Returns the (non-null) session when present; otherwise **throws a `Response`** (302/401) — nifra
65
+ * returns a thrown `Response` as-is, short-circuiting the rest of the handler.
66
+ *
67
+ * ```ts
68
+ * const { user } = await requireSession(auth, c.req, { redirectTo: "/login" })
69
+ * ```
70
+ */
71
+ export async function requireSession(auth, request, options = {}) {
72
+ const session = await getSession(auth, request);
73
+ if (session !== null)
74
+ return session;
75
+ throw rejection(options);
76
+ }
77
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,oBAAoB,EAAE,MAAM,eAAe,CAAA;AAgCpE,oGAAoG;AACpG,MAAM,eAAe,GAAG,CAAC,KAAK,EAAE,MAAM,CAAU,CAAA;AAEhD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,UAAU,CAAC,IAAoB,EAAE,UAA6B,EAAE;IAC9E,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO,EAAE,QAAQ,IAAI,WAAW,CAAA;IACtE,MAAM,OAAO,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,CAAA,CAAC,sDAAsD;IACtG,mGAAmG;IACnG,sGAAsG;IACtG,qGAAqG;IACrG,oGAAoG;IACpG,OAAO,oBAAoB,CAAC,aAAa,EAAE,CAAsB,GAAM,EAAK,EAAE;QAC5E,KAAK,MAAM,MAAM,IAAI,eAAe,EAAE,CAAC;YACrC,iGAAiG;YACjG,oGAAoG;YACpG,GAAG,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAA4B,EAAE,EAAE,CACxE,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CACpB,CAAA;QACH,CAAC;QACD,OAAO,GAAG,CAAA;IACZ,CAAC,CAAC,CAAA;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,UAAU,CACxB,IAAO,EACP,OAAgB;IAEhB,iGAAiG;IACjG,gFAAgF;IAChF,OAAO,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAiC,CAAA;AAC1F,CAAC;AAUD,MAAM,SAAS,GAAG,CAAC,OAA8B,EAAY,EAAE;IAC7D,MAAM,EAAE,GAAG,OAAO,CAAC,UAAU,CAAA;IAC7B,IAAI,EAAE,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;IACjG,0GAA0G;IAC1G,yGAAyG;IACzG,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,MAAM,IAAI,KAAK,CACb,oGAAoG,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,CAC1H,CAAA;IACH,CAAC;IACD,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;AACvE,CAAC,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,IAAO,EACP,OAAgB,EAChB,UAAiC,EAAE;IAEnC,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IAC/C,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,OAAO,CAAA;IACpC,MAAM,SAAS,CAAC,OAAO,CAAC,CAAA;AAC1B,CAAC"}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@nifrajs/better-auth",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Mount better-auth into a nifra app — one app.use() wires /api/auth/*, plus typed getSession/requireSession guards. Structural typing: no hard dependency on better-auth.",
5
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "web-framework",
10
+ "full-stack",
11
+ "edge",
12
+ "ssr",
13
+ "auth",
14
+ "authentication",
15
+ "sessions",
16
+ "oauth",
17
+ "better-auth"
18
+ ],
19
+ "type": "module",
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "bun": "./src/index.ts",
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src"
33
+ ],
34
+ "sideEffects": false,
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
37
+ },
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "peerDependencies": {
43
+ "@nifrajs/core": "workspace:*",
44
+ "better-auth": ">=1.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "better-auth": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "@nifrajs/client": "workspace:*",
53
+ "@nifrajs/core": "workspace:*",
54
+ "@nifrajs/test-utils": "workspace:*"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/nifrajs/nifra.git",
59
+ "directory": "packages/better-auth"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/nifrajs/nifra/issues"
63
+ },
64
+ "homepage": "https://github.com/nifrajs/nifra#readme"
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { type AnyServer, defineIdentityPlugin } from "@nifrajs/core"
2
+
3
+ /**
4
+ * The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
5
+ * Declared structurally rather than imported, so `@nifrajs/better-auth` has **no runtime dependency** on
6
+ * better-auth: you pass your own `auth` object and its concrete types flow through
7
+ * {@link getSession} / {@link requireSession} via inference.
8
+ */
9
+ export interface BetterAuthLike {
10
+ /** better-auth's catch-all handler — serves every request under `basePath`. */
11
+ readonly handler: (request: Request) => Response | Promise<Response>
12
+ readonly api: {
13
+ /** Resolve the session from request headers (cookie or bearer). Returns a nullable payload. */
14
+ readonly getSession: (context: { readonly headers: Headers }) => Promise<unknown>
15
+ }
16
+ /** better-auth's resolved options; `basePath` (when set) defaults the mount path. */
17
+ readonly options?: { readonly basePath?: string }
18
+ }
19
+
20
+ /**
21
+ * The non-null session payload of a concrete better-auth instance `A`, inferred from its
22
+ * `api.getSession` return type (typically `{ user: User; session: Session }`).
23
+ */
24
+ export type SessionOf<A extends BetterAuthLike> = NonNullable<
25
+ Awaited<ReturnType<A["api"]["getSession"]>>
26
+ >
27
+
28
+ export interface BetterAuthOptions {
29
+ /** Mount path for better-auth's routes. Defaults to `auth.options.basePath`, then `"/api/auth"`. */
30
+ readonly basePath?: string
31
+ }
32
+
33
+ // better-auth dispatches only GET (session, OAuth/email callbacks) and POST (sign-in/up/out, etc.).
34
+ const HANDLED_METHODS = ["GET", "POST"] as const
35
+
36
+ /**
37
+ * Mount a better-auth instance into a nifra app: registers its handler at `${basePath}/*`
38
+ * (default `/api/auth/*`) for `GET` + `POST`, so every better-auth endpoint — sign-in/up/out, OAuth
39
+ * callbacks, session, 2FA, magic links, … — is served by your nifra server.
40
+ *
41
+ * ```ts
42
+ * import { betterAuth, requireSession } from "@nifrajs/better-auth"
43
+ * import { auth } from "./auth" // your configured better-auth instance
44
+ *
45
+ * const app = server().use(betterAuth(auth)) // wires /api/auth/*
46
+ * .get("/me", async (c) => (await requireSession(auth, c.req)).user)
47
+ * ```
48
+ *
49
+ * Idempotent (named `"better-auth"` — applying twice mounts once). Read the session with
50
+ * {@link getSession} / {@link requireSession}, which infer your better-auth types.
51
+ */
52
+ export function betterAuth(auth: BetterAuthLike, options: BetterAuthOptions = {}) {
53
+ const base = options.basePath ?? auth.options?.basePath ?? "/api/auth"
54
+ const pattern = `${base.replace(/\/+$/, "")}/*` // strip trailing slash(es), then wildcard the subtree
55
+ // A type-IDENTITY plugin (see defineIdentityPlugin): mounting the auth handler must NOT change the
56
+ // route registry's type. A plain `definePlugin((app) => app)` would infer `app: Server<any, any>`, so
57
+ // `use`'s result — and the entire typed client derived from it — collapsed to `any`. This was the #1
58
+ // reported anti-drift bug: routes declared after `.use(betterAuth(...))` silently lost their types.
59
+ return defineIdentityPlugin("better-auth", <S extends AnyServer>(app: S): S => {
60
+ for (const method of HANDLED_METHODS) {
61
+ // `register`'s handler is typed `(context: never) => unknown`; the framework invokes it with the
62
+ // real Context, so reading `c.req` is sound. Returning better-auth's Response passes through as-is.
63
+ app.register(method, pattern, undefined, (c: { readonly req: Request }) =>
64
+ auth.handler(c.req),
65
+ )
66
+ }
67
+ return app
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Resolve the better-auth session for a request — a thin, typed wrapper over `auth.api.getSession`.
73
+ * Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
74
+ * (`c.req`) and web loaders/actions (`request`).
75
+ *
76
+ * ```ts
77
+ * const session = await getSession(auth, c.req) // typed: { user, session } | null
78
+ * if (session) c.set.headers["x-user"] = session.user.id
79
+ * ```
80
+ */
81
+ export function getSession<A extends BetterAuthLike>(
82
+ auth: A,
83
+ request: Request,
84
+ ): Promise<SessionOf<A> | null> {
85
+ // `auth` is the concrete `A`, so `getSession`'s real return type is recovered by `SessionOf<A>`;
86
+ // the cast bridges the erased `Promise<unknown>` view inside this generic body.
87
+ return auth.api.getSession({ headers: request.headers }) as Promise<SessionOf<A> | null>
88
+ }
89
+
90
+ /**
91
+ * What {@link requireSession} does on a missing session: `302` to `redirectTo` (a same-origin path),
92
+ * or — when omitted — a `401` JSON (`{ ok: false, error: "unauthorized" }`). Mirrors `@nifrajs/auth` guards.
93
+ */
94
+ export interface RequireSessionOptions {
95
+ readonly redirectTo?: string
96
+ }
97
+
98
+ const rejection = (options: RequireSessionOptions): Response => {
99
+ const to = options.redirectTo
100
+ if (to === undefined) return Response.json({ ok: false, error: "unauthorized" }, { status: 401 })
101
+ // Same-origin guard (mirrors @nifrajs/web `redirect` + @nifrajs/auth guards): a single leading "/", never
102
+ // "//host" or an absolute URL. `redirectTo` is dev-authored, so a bad value is a config bug — fail loud.
103
+ if (!to.startsWith("/") || to.startsWith("//")) {
104
+ throw new Error(
105
+ `[nifra/better-auth] requireSession redirectTo must be a same-origin path beginning with "/" (got ${JSON.stringify(to)})`,
106
+ )
107
+ }
108
+ return new Response(null, { status: 302, headers: { location: to } })
109
+ }
110
+
111
+ /**
112
+ * Require an authenticated better-auth session at the top of a protected handler/loader/action.
113
+ * Returns the (non-null) session when present; otherwise **throws a `Response`** (302/401) — nifra
114
+ * returns a thrown `Response` as-is, short-circuiting the rest of the handler.
115
+ *
116
+ * ```ts
117
+ * const { user } = await requireSession(auth, c.req, { redirectTo: "/login" })
118
+ * ```
119
+ */
120
+ export async function requireSession<A extends BetterAuthLike>(
121
+ auth: A,
122
+ request: Request,
123
+ options: RequireSessionOptions = {},
124
+ ): Promise<SessionOf<A>> {
125
+ const session = await getSession(auth, request)
126
+ if (session !== null) return session
127
+ throw rejection(options)
128
+ }