@nifrajs/better-auth 1.13.0 → 2.1.0

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/README.md CHANGED
@@ -30,7 +30,7 @@ export const auth = createBetterAuth({
30
30
 
31
31
  ```ts
32
32
  // server.ts
33
- import { server } from "@nifrajs/core"
33
+ import { server } from "@nifrajs/core/server"
34
34
  import { betterAuth } from "@nifrajs/better-auth"
35
35
  import { auth } from "./auth"
36
36
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type AnyServer, type Server } from "@nifrajs/core/server";
1
2
  /**
2
3
  * The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
3
4
  * Declared structurally rather than imported, so `@nifrajs/better-auth` has **no runtime dependency** on
@@ -49,7 +50,7 @@ export interface BetterAuthOptions {
49
50
  * Idempotent (named `"better-auth"` — applying twice mounts once). Read the session with
50
51
  * {@link getSession} / {@link requireSession}, which infer your better-auth types.
51
52
  */
52
- export declare function betterAuth(auth: BetterAuthLike, options?: BetterAuthOptions): import("@nifrajs/core").IdentityPlugin;
53
+ export declare function betterAuth(auth: BetterAuthLike, options?: BetterAuthOptions): import("@nifrajs/core/server").IdentityPlugin;
53
54
  /**
54
55
  * Resolve the better-auth session for a request — a thin, typed wrapper over `auth.api.getSession`.
55
56
  * Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
@@ -78,4 +79,96 @@ export interface RequireSessionOptions {
78
79
  * ```
79
80
  */
80
81
  export declare function requireSession<A extends BetterAuthLike>(auth: A, request: Request, options?: RequireSessionOptions): Promise<SessionOf<A>>;
82
+ /**
83
+ * The authenticated caller of a request, mapped from a better-auth session. Built by
84
+ * {@link requirePrincipal} / {@link authed} and threaded onto the handler context as `c.principal`.
85
+ *
86
+ * `tenantId` is optional here (`string | undefined`); with `{ requireTenant: true }` it is narrowed to a
87
+ * non-optional `string` (see {@link authed}), so a tenant-scoped handler never has to null-check it.
88
+ * nifra owns the session -> principal wiring ONLY; binding the principal to a DB/RLS scope stays in
89
+ * userland (nifra is storage-agnostic and adds no DB code).
90
+ */
91
+ export interface Principal<User> {
92
+ /** The full better-auth user record, typed from the concrete auth instance. */
93
+ readonly user: User;
94
+ /** The user's id (`user.id`). */
95
+ readonly userId: string;
96
+ /** The session's id (`session.id`). */
97
+ readonly sessionId: string;
98
+ /** The resolved tenant/org id, if any. Non-optional `string` under `{ requireTenant: true }`. */
99
+ readonly tenantId?: string;
100
+ }
101
+ /**
102
+ * The non-null user type of a concrete better-auth instance `A` (`SessionOf<A>["user"]`). Collapses to
103
+ * `unknown` only for the erased structural `BetterAuthLike`; a real instance recovers the concrete user.
104
+ */
105
+ export type SessionUserOf<A extends BetterAuthLike> = SessionOf<A> extends {
106
+ user: infer U;
107
+ } ? U : unknown;
108
+ /**
109
+ * Options for {@link requirePrincipal} / {@link authed}.
110
+ */
111
+ export interface AuthedOptions<User> {
112
+ /** Require a resolvable tenant. When set and no tenant resolves, fail closed with `403`. */
113
+ readonly requireTenant?: boolean;
114
+ /** Browser redirect target (`302`) for a missing session, instead of the default `401` JSON. Same-origin. */
115
+ readonly redirectTo?: string;
116
+ /** Resolve the tenant id from the user. Default: `user.tenantId ?? user.orgId` (string-valued only). */
117
+ readonly tenantOf?: (user: User) => string | undefined;
118
+ }
119
+ /**
120
+ * The principal type for a given `requireTenant` flag: `tenantId` narrows to a required `string` when
121
+ * `requireTenant` is `true`, otherwise stays optional (`string | undefined`). The flag is captured as a
122
+ * literal `const` type parameter at the call sites so `{ requireTenant: true }` selects the narrowed branch.
123
+ */
124
+ export type PrincipalFor<User, RequireTenant extends boolean> = RequireTenant extends true ? Principal<User> & {
125
+ readonly tenantId: string;
126
+ } : Principal<User>;
127
+ /** Add `{ principal: P }` to a server's context while preserving its route registry `R` (no collapse to
128
+ * `any`). This is the type that makes `.use(authed(auth))` thread a NON-NULL `c.principal`. */
129
+ export type WithPrincipal<S extends AnyServer, P> = S extends Server<infer R, infer C> ? Server<R, C & {
130
+ principal: P;
131
+ }> : never;
132
+ /**
133
+ * Resolve the better-auth session and map it to a {@link Principal}, or **throw a `Response`** so the
134
+ * handler never runs unauthenticated:
135
+ *
136
+ * - No/invalid session -> `302` to `options.redirectTo` when set, else `401` JSON (`requireSession`).
137
+ * - `requireTenant: true` and no tenant resolves -> `403` JSON (`{ ok: false, error: "forbidden" }`).
138
+ *
139
+ * nifra returns a thrown `Response` as-is (short-circuit), so this is the fail-closed guard used inline
140
+ * or by {@link authed}. `tenantId` is a non-optional `string` in the return type when `requireTenant`.
141
+ *
142
+ * ```ts
143
+ * const principal = await requirePrincipal(auth, c.req, { requireTenant: true })
144
+ * // principal.userId / principal.tenantId are typed `string`, no null-check
145
+ * ```
146
+ */
147
+ export declare function requirePrincipal<A extends BetterAuthLike, const RequireTenant extends boolean = false>(auth: A, request: Request, options?: AuthedOptions<SessionUserOf<A>> & {
148
+ readonly requireTenant?: RequireTenant;
149
+ }): Promise<PrincipalFor<SessionUserOf<A>, RequireTenant>>;
150
+ /**
151
+ * A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
152
+ * `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
153
+ * and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
154
+ * forgotten. Works in both modes:
155
+ *
156
+ * ```ts
157
+ * // inline
158
+ * const app = server().use(authed(auth)).get("/me", (c) => ({ id: c.principal.userId }))
159
+ * // contract-first (the pre-applied derive threads `principal` into the contract's handlers)
160
+ * const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
161
+ * ```
162
+ *
163
+ * With `{ requireTenant: true }`, `c.principal.tenantId` is typed `string` (a missing tenant is a `403`).
164
+ *
165
+ * DESIGN NOTE: this is an **unnamed** plugin by necessity. A named plugin (for idempotent dedupe) carries
166
+ * a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
167
+ * overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
168
+ * Threading a NON-NULL principal is the whole point, so `authed` stays unnamed and generic. Applying it
169
+ * twice simply derives twice (the second resolve overwrites with the same value); scope it once per app.
170
+ */
171
+ export declare function authed<A extends BetterAuthLike, const RequireTenant extends boolean = false>(auth: A, options?: AuthedOptions<SessionUserOf<A>> & {
172
+ readonly requireTenant?: RequireTenant;
173
+ }): <S extends AnyServer>(app: S) => WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>>;
81
174
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC5E;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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { defineIdentityPlugin } from "@nifrajs/core";
1
+ import { defineIdentityPlugin } from "@nifrajs/core/server";
2
2
  // better-auth dispatches only GET (session, OAuth/email callbacks) and POST (sign-in/up/out, etc.).
3
3
  const HANDLED_METHODS = ["GET", "POST"];
4
4
  /**
@@ -74,4 +74,73 @@ export async function requireSession(auth, request, options = {}) {
74
74
  return session;
75
75
  throw rejection(options);
76
76
  }
77
+ const forbidden = () => Response.json({ ok: false, error: "forbidden" }, { status: 403 });
78
+ /**
79
+ * Resolve the better-auth session and map it to a {@link Principal}, or **throw a `Response`** so the
80
+ * handler never runs unauthenticated:
81
+ *
82
+ * - No/invalid session -> `302` to `options.redirectTo` when set, else `401` JSON (`requireSession`).
83
+ * - `requireTenant: true` and no tenant resolves -> `403` JSON (`{ ok: false, error: "forbidden" }`).
84
+ *
85
+ * nifra returns a thrown `Response` as-is (short-circuit), so this is the fail-closed guard used inline
86
+ * or by {@link authed}. `tenantId` is a non-optional `string` in the return type when `requireTenant`.
87
+ *
88
+ * ```ts
89
+ * const principal = await requirePrincipal(auth, c.req, { requireTenant: true })
90
+ * // principal.userId / principal.tenantId are typed `string`, no null-check
91
+ * ```
92
+ */
93
+ export async function requirePrincipal(auth, request, options) {
94
+ // Reuse requireSession's throw path (302/redirect or 401) verbatim - one owner for the no-session gate.
95
+ const session = await requireSession(auth, request, options?.redirectTo !== undefined ? { redirectTo: options.redirectTo } : {});
96
+ // better-auth sessions are `{ user: { id: string, ... }, session: { id: string, ... } }`; view the
97
+ // fields we map. The concrete user type flows through `SessionUserOf<A>` for `principal.user`.
98
+ const view = session;
99
+ const user = view.user;
100
+ const userId = user.id;
101
+ const sessionId = view.session.id;
102
+ const resolveTenant = options?.tenantOf ??
103
+ ((u) => {
104
+ const record = u;
105
+ const value = record.tenantId ?? record.orgId;
106
+ return typeof value === "string" ? value : undefined;
107
+ });
108
+ // A blank tenant is NOT a resolved tenant: a NOT-NULL column defaulted to "" (or a custom `tenantOf`
109
+ // returning "") must fail closed under `requireTenant`, never bind the principal to an empty tenant.
110
+ const resolved = resolveTenant(user);
111
+ const tenantId = resolved === undefined || resolved === "" ? undefined : resolved;
112
+ if (options?.requireTenant === true && tenantId === undefined)
113
+ throw forbidden();
114
+ // Build the principal without ever assigning `tenantId: undefined` (exactOptionalPropertyTypes). The
115
+ // single cast maps our own constructed object onto the conditional `PrincipalFor` return - the runtime
116
+ // shape is exactly the mapped session, no untrusted data crosses here.
117
+ const principal = tenantId === undefined ? { user, userId, sessionId } : { user, userId, sessionId, tenantId };
118
+ return principal;
119
+ }
120
+ /**
121
+ * A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
122
+ * `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
123
+ * and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
124
+ * forgotten. Works in both modes:
125
+ *
126
+ * ```ts
127
+ * // inline
128
+ * const app = server().use(authed(auth)).get("/me", (c) => ({ id: c.principal.userId }))
129
+ * // contract-first (the pre-applied derive threads `principal` into the contract's handlers)
130
+ * const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
131
+ * ```
132
+ *
133
+ * With `{ requireTenant: true }`, `c.principal.tenantId` is typed `string` (a missing tenant is a `403`).
134
+ *
135
+ * DESIGN NOTE: this is an **unnamed** plugin by necessity. A named plugin (for idempotent dedupe) carries
136
+ * a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
137
+ * overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
138
+ * Threading a NON-NULL principal is the whole point, so `authed` stays unnamed and generic. Applying it
139
+ * twice simply derives twice (the second resolve overwrites with the same value); scope it once per app.
140
+ */
141
+ export function authed(auth, options) {
142
+ return (app) => app.derive(async (c) => ({
143
+ principal: await requirePrincipal(auth, c.req, options),
144
+ }));
145
+ }
77
146
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,oBAAoB,EAAE,MAAM,eAAe,CAAA;AAsCpE,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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/better-auth",
3
- "version": "1.13.0",
3
+ "version": "2.1.0",
4
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
5
  "keywords": [
6
6
  "bun",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "peerDependencies": {
43
- "@nifrajs/core": "^1.13.0",
43
+ "@nifrajs/core": "^2.1.0",
44
44
  "better-auth": ">=1.0.0"
45
45
  },
46
46
  "peerDependenciesMeta": {
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AnyServer, defineIdentityPlugin } from "@nifrajs/core"
1
+ import { type AnyServer, defineIdentityPlugin, type Server } from "@nifrajs/core/server"
2
2
 
3
3
  /**
4
4
  * The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
@@ -132,3 +132,152 @@ export async function requireSession<A extends BetterAuthLike>(
132
132
  if (session !== null) return session
133
133
  throw rejection(options)
134
134
  }
135
+
136
+ /**
137
+ * The authenticated caller of a request, mapped from a better-auth session. Built by
138
+ * {@link requirePrincipal} / {@link authed} and threaded onto the handler context as `c.principal`.
139
+ *
140
+ * `tenantId` is optional here (`string | undefined`); with `{ requireTenant: true }` it is narrowed to a
141
+ * non-optional `string` (see {@link authed}), so a tenant-scoped handler never has to null-check it.
142
+ * nifra owns the session -> principal wiring ONLY; binding the principal to a DB/RLS scope stays in
143
+ * userland (nifra is storage-agnostic and adds no DB code).
144
+ */
145
+ export interface Principal<User> {
146
+ /** The full better-auth user record, typed from the concrete auth instance. */
147
+ readonly user: User
148
+ /** The user's id (`user.id`). */
149
+ readonly userId: string
150
+ /** The session's id (`session.id`). */
151
+ readonly sessionId: string
152
+ /** The resolved tenant/org id, if any. Non-optional `string` under `{ requireTenant: true }`. */
153
+ readonly tenantId?: string
154
+ }
155
+
156
+ /**
157
+ * The non-null user type of a concrete better-auth instance `A` (`SessionOf<A>["user"]`). Collapses to
158
+ * `unknown` only for the erased structural `BetterAuthLike`; a real instance recovers the concrete user.
159
+ */
160
+ export type SessionUserOf<A extends BetterAuthLike> =
161
+ SessionOf<A> extends { user: infer U } ? U : unknown
162
+
163
+ /**
164
+ * Options for {@link requirePrincipal} / {@link authed}.
165
+ */
166
+ export interface AuthedOptions<User> {
167
+ /** Require a resolvable tenant. When set and no tenant resolves, fail closed with `403`. */
168
+ readonly requireTenant?: boolean
169
+ /** Browser redirect target (`302`) for a missing session, instead of the default `401` JSON. Same-origin. */
170
+ readonly redirectTo?: string
171
+ /** Resolve the tenant id from the user. Default: `user.tenantId ?? user.orgId` (string-valued only). */
172
+ readonly tenantOf?: (user: User) => string | undefined
173
+ }
174
+
175
+ /**
176
+ * The principal type for a given `requireTenant` flag: `tenantId` narrows to a required `string` when
177
+ * `requireTenant` is `true`, otherwise stays optional (`string | undefined`). The flag is captured as a
178
+ * literal `const` type parameter at the call sites so `{ requireTenant: true }` selects the narrowed branch.
179
+ */
180
+ export type PrincipalFor<User, RequireTenant extends boolean> = RequireTenant extends true
181
+ ? Principal<User> & { readonly tenantId: string }
182
+ : Principal<User>
183
+
184
+ /** Add `{ principal: P }` to a server's context while preserving its route registry `R` (no collapse to
185
+ * `any`). This is the type that makes `.use(authed(auth))` thread a NON-NULL `c.principal`. */
186
+ export type WithPrincipal<S extends AnyServer, P> =
187
+ S extends Server<infer R, infer C> ? Server<R, C & { principal: P }> : never
188
+
189
+ const forbidden = (): Response => Response.json({ ok: false, error: "forbidden" }, { status: 403 })
190
+
191
+ /**
192
+ * Resolve the better-auth session and map it to a {@link Principal}, or **throw a `Response`** so the
193
+ * handler never runs unauthenticated:
194
+ *
195
+ * - No/invalid session -> `302` to `options.redirectTo` when set, else `401` JSON (`requireSession`).
196
+ * - `requireTenant: true` and no tenant resolves -> `403` JSON (`{ ok: false, error: "forbidden" }`).
197
+ *
198
+ * nifra returns a thrown `Response` as-is (short-circuit), so this is the fail-closed guard used inline
199
+ * or by {@link authed}. `tenantId` is a non-optional `string` in the return type when `requireTenant`.
200
+ *
201
+ * ```ts
202
+ * const principal = await requirePrincipal(auth, c.req, { requireTenant: true })
203
+ * // principal.userId / principal.tenantId are typed `string`, no null-check
204
+ * ```
205
+ */
206
+ export async function requirePrincipal<
207
+ A extends BetterAuthLike,
208
+ const RequireTenant extends boolean = false,
209
+ >(
210
+ auth: A,
211
+ request: Request,
212
+ options?: AuthedOptions<SessionUserOf<A>> & { readonly requireTenant?: RequireTenant },
213
+ ): Promise<PrincipalFor<SessionUserOf<A>, RequireTenant>> {
214
+ // Reuse requireSession's throw path (302/redirect or 401) verbatim - one owner for the no-session gate.
215
+ const session = await requireSession(
216
+ auth,
217
+ request,
218
+ options?.redirectTo !== undefined ? { redirectTo: options.redirectTo } : {},
219
+ )
220
+ // better-auth sessions are `{ user: { id: string, ... }, session: { id: string, ... } }`; view the
221
+ // fields we map. The concrete user type flows through `SessionUserOf<A>` for `principal.user`.
222
+ const view = session as unknown as {
223
+ readonly user: SessionUserOf<A>
224
+ readonly session: { readonly id: string }
225
+ }
226
+ const user = view.user
227
+ const userId = (user as { readonly id: string }).id
228
+ const sessionId = view.session.id
229
+
230
+ const resolveTenant =
231
+ options?.tenantOf ??
232
+ ((u: SessionUserOf<A>): string | undefined => {
233
+ const record = u as { readonly tenantId?: unknown; readonly orgId?: unknown }
234
+ const value = record.tenantId ?? record.orgId
235
+ return typeof value === "string" ? value : undefined
236
+ })
237
+ // A blank tenant is NOT a resolved tenant: a NOT-NULL column defaulted to "" (or a custom `tenantOf`
238
+ // returning "") must fail closed under `requireTenant`, never bind the principal to an empty tenant.
239
+ const resolved = resolveTenant(user)
240
+ const tenantId = resolved === undefined || resolved === "" ? undefined : resolved
241
+
242
+ if (options?.requireTenant === true && tenantId === undefined) throw forbidden()
243
+
244
+ // Build the principal without ever assigning `tenantId: undefined` (exactOptionalPropertyTypes). The
245
+ // single cast maps our own constructed object onto the conditional `PrincipalFor` return - the runtime
246
+ // shape is exactly the mapped session, no untrusted data crosses here.
247
+ const principal: Principal<SessionUserOf<A>> =
248
+ tenantId === undefined ? { user, userId, sessionId } : { user, userId, sessionId, tenantId }
249
+ return principal as PrincipalFor<SessionUserOf<A>, RequireTenant>
250
+ }
251
+
252
+ /**
253
+ * A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
254
+ * `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
255
+ * and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
256
+ * forgotten. Works in both modes:
257
+ *
258
+ * ```ts
259
+ * // inline
260
+ * const app = server().use(authed(auth)).get("/me", (c) => ({ id: c.principal.userId }))
261
+ * // contract-first (the pre-applied derive threads `principal` into the contract's handlers)
262
+ * const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
263
+ * ```
264
+ *
265
+ * With `{ requireTenant: true }`, `c.principal.tenantId` is typed `string` (a missing tenant is a `403`).
266
+ *
267
+ * DESIGN NOTE: this is an **unnamed** plugin by necessity. A named plugin (for idempotent dedupe) carries
268
+ * a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
269
+ * overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
270
+ * Threading a NON-NULL principal is the whole point, so `authed` stays unnamed and generic. Applying it
271
+ * twice simply derives twice (the second resolve overwrites with the same value); scope it once per app.
272
+ */
273
+ export function authed<A extends BetterAuthLike, const RequireTenant extends boolean = false>(
274
+ auth: A,
275
+ options?: AuthedOptions<SessionUserOf<A>> & { readonly requireTenant?: RequireTenant },
276
+ ): <S extends AnyServer>(
277
+ app: S,
278
+ ) => WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>> {
279
+ return <S extends AnyServer>(app: S) =>
280
+ app.derive(async (c: { readonly req: Request }) => ({
281
+ principal: await requirePrincipal(auth, c.req, options),
282
+ })) as unknown as WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>>
283
+ }