@cfast/auth 0.0.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/dist/client.js ADDED
@@ -0,0 +1,226 @@
1
+ // src/client/auth-provider.tsx
2
+ import { createContext, useContext } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ var AuthContext = createContext(null);
5
+ function AuthProvider({
6
+ user,
7
+ loginPath = "/login",
8
+ children
9
+ }) {
10
+ return /* @__PURE__ */ jsx(AuthContext.Provider, { value: { user, loginPath }, children });
11
+ }
12
+ function useAuthContext(hookName) {
13
+ const ctx = useContext(AuthContext);
14
+ if (ctx === null) {
15
+ throw new Error(`${hookName} must be used within an <AuthProvider>`);
16
+ }
17
+ return ctx;
18
+ }
19
+ function useCurrentUser() {
20
+ return useAuthContext("useCurrentUser").user;
21
+ }
22
+ function useLoginPath() {
23
+ return useAuthContext("useLoginPath").loginPath;
24
+ }
25
+
26
+ // src/client/auth-guard.tsx
27
+ import { jsx as jsx2 } from "react/jsx-runtime";
28
+ function AuthGuard({ user, children }) {
29
+ return /* @__PURE__ */ jsx2(AuthProvider, { user, children });
30
+ }
31
+
32
+ // src/client/auth-client-provider.tsx
33
+ import { createContext as createContext2, useContext as useContext2 } from "react";
34
+ import { jsx as jsx3 } from "react/jsx-runtime";
35
+ var AuthClientContext = createContext2(null);
36
+ function AuthClientProvider({
37
+ authClient,
38
+ children
39
+ }) {
40
+ return /* @__PURE__ */ jsx3(AuthClientContext.Provider, { value: authClient, children });
41
+ }
42
+ function useAuth() {
43
+ const authClient = useContext2(AuthClientContext);
44
+ if (authClient === null) {
45
+ throw new Error("useAuth must be used within an <AuthClientProvider>");
46
+ }
47
+ return {
48
+ signOut: async () => {
49
+ await authClient.signOut();
50
+ },
51
+ registerPasskey: async () => {
52
+ if (!authClient.passkey) {
53
+ throw new Error("Passkey plugin not configured on auth client");
54
+ }
55
+ return authClient.passkey.addPasskey();
56
+ },
57
+ deletePasskey: async (id) => {
58
+ if (!authClient.passkey) {
59
+ throw new Error("Passkey plugin not configured on auth client");
60
+ }
61
+ return authClient.passkey.deletePasskey({ id });
62
+ },
63
+ stopImpersonating: async () => {
64
+ if (!authClient.admin) {
65
+ throw new Error("Admin plugin not configured on auth client");
66
+ }
67
+ await authClient.admin.stopImpersonating();
68
+ },
69
+ authClient
70
+ };
71
+ }
72
+
73
+ // src/client/login-page.tsx
74
+ import { useState } from "react";
75
+ import { jsx as jsx4, jsxs } from "react/jsx-runtime";
76
+ function DefaultLayout({ children }) {
77
+ return /* @__PURE__ */ jsx4(
78
+ "div",
79
+ {
80
+ style: {
81
+ display: "flex",
82
+ justifyContent: "center",
83
+ alignItems: "center",
84
+ minHeight: "100vh"
85
+ },
86
+ children: /* @__PURE__ */ jsx4("div", { style: { maxWidth: 400, width: "100%", padding: 32 }, children })
87
+ }
88
+ );
89
+ }
90
+ function DefaultEmailInput({
91
+ value,
92
+ onChange
93
+ }) {
94
+ return /* @__PURE__ */ jsx4(
95
+ "input",
96
+ {
97
+ type: "email",
98
+ placeholder: "you@example.com",
99
+ value,
100
+ onChange: (e) => onChange(e.target.value),
101
+ required: true,
102
+ style: { width: "100%", padding: 8, boxSizing: "border-box" }
103
+ }
104
+ );
105
+ }
106
+ function DefaultMagicLinkButton({
107
+ onClick,
108
+ loading
109
+ }) {
110
+ return /* @__PURE__ */ jsx4(
111
+ "button",
112
+ {
113
+ type: "button",
114
+ onClick,
115
+ disabled: loading,
116
+ style: { width: "100%", padding: 8 },
117
+ children: loading ? "Sending..." : "Send Magic Link"
118
+ }
119
+ );
120
+ }
121
+ function DefaultPasskeyButton({
122
+ onClick,
123
+ loading
124
+ }) {
125
+ return /* @__PURE__ */ jsx4(
126
+ "button",
127
+ {
128
+ type: "button",
129
+ onClick,
130
+ disabled: loading,
131
+ style: { width: "100%", padding: 8 },
132
+ children: loading ? "Verifying..." : "Sign in with Passkey"
133
+ }
134
+ );
135
+ }
136
+ function DefaultSuccessMessage({ email }) {
137
+ return /* @__PURE__ */ jsxs("div", { role: "status", children: [
138
+ "Check your email (",
139
+ email,
140
+ ") for a magic link to sign in."
141
+ ] });
142
+ }
143
+ function DefaultErrorMessage({ error }) {
144
+ return /* @__PURE__ */ jsx4("div", { role: "alert", style: { color: "red" }, children: error });
145
+ }
146
+ function LoginPage({
147
+ authClient,
148
+ components = {},
149
+ title = "Sign In",
150
+ subtitle,
151
+ onSuccess
152
+ }) {
153
+ const Layout = components.Layout ?? DefaultLayout;
154
+ const EmailInput = components.EmailInput ?? DefaultEmailInput;
155
+ const MagicLinkBtn = components.MagicLinkButton ?? DefaultMagicLinkButton;
156
+ const PasskeyBtn = components.PasskeyButton ?? DefaultPasskeyButton;
157
+ const SuccessMsg = components.SuccessMessage ?? DefaultSuccessMessage;
158
+ const ErrorMsg = components.ErrorMessage ?? DefaultErrorMessage;
159
+ const [email, setEmail] = useState("");
160
+ const [sent, setSent] = useState(false);
161
+ const [loading, setLoading] = useState(false);
162
+ const [error, setError] = useState(null);
163
+ const [passkeyLoading, setPasskeyLoading] = useState(false);
164
+ async function handleMagicLink() {
165
+ if (!email.trim()) {
166
+ setError("Please enter your email address.");
167
+ return;
168
+ }
169
+ setLoading(true);
170
+ setError(null);
171
+ try {
172
+ const result = await authClient.signIn.magicLink({ email });
173
+ if (result.error) {
174
+ setError(result.error.message ?? "Failed to send magic link.");
175
+ } else {
176
+ setSent(true);
177
+ onSuccess?.();
178
+ }
179
+ } catch {
180
+ setError("An unexpected error occurred. Please try again.");
181
+ } finally {
182
+ setLoading(false);
183
+ }
184
+ }
185
+ async function handlePasskey() {
186
+ setPasskeyLoading(true);
187
+ setError(null);
188
+ try {
189
+ const result = await authClient.signIn.passkey?.();
190
+ if (result?.error) {
191
+ setError(result.error.message ?? "Passkey sign-in failed.");
192
+ } else {
193
+ onSuccess?.();
194
+ }
195
+ } catch {
196
+ setError("Passkey sign-in failed. Please try again.");
197
+ } finally {
198
+ setPasskeyLoading(false);
199
+ }
200
+ }
201
+ return /* @__PURE__ */ jsxs(Layout, { children: [
202
+ /* @__PURE__ */ jsx4("h2", { children: title }),
203
+ subtitle && /* @__PURE__ */ jsx4("p", { children: subtitle }),
204
+ error && /* @__PURE__ */ jsx4(ErrorMsg, { error }),
205
+ sent ? /* @__PURE__ */ jsx4(SuccessMsg, { email }) : /* @__PURE__ */ jsxs("div", { children: [
206
+ /* @__PURE__ */ jsx4(EmailInput, { value: email, onChange: setEmail }),
207
+ /* @__PURE__ */ jsx4("div", { style: { marginTop: 8 }, children: /* @__PURE__ */ jsx4(MagicLinkBtn, { onClick: handleMagicLink, loading }) }),
208
+ authClient?.signIn?.passkey && /* @__PURE__ */ jsx4("div", { style: { marginTop: 8 }, children: /* @__PURE__ */ jsx4(PasskeyBtn, { onClick: handlePasskey, loading: passkeyLoading }) })
209
+ ] })
210
+ ] });
211
+ }
212
+
213
+ // src/client/create-auth-client.ts
214
+ import { createAuthClient } from "better-auth/react";
215
+ import { magicLinkClient } from "better-auth/client/plugins";
216
+ export {
217
+ AuthClientProvider,
218
+ AuthGuard,
219
+ AuthProvider,
220
+ LoginPage,
221
+ createAuthClient,
222
+ magicLinkClient,
223
+ useAuth,
224
+ useCurrentUser,
225
+ useLoginPath
226
+ };
@@ -0,0 +1,167 @@
1
+ import { a as AuthConfig, b as AuthEnvConfig, c as AuthInstance } from './types-ghXti5CW.js';
2
+ export { d as AuthContext, A as AuthUser, e as AuthenticatedContext } from './types-ghXti5CW.js';
3
+ import '@cfast/permissions';
4
+
5
+ /**
6
+ * Creates a pre-configured auth factory for Cloudflare Workers.
7
+ *
8
+ * Returns an `initAuth()` function that accepts per-request environment bindings
9
+ * ({@link AuthEnvConfig}) and produces a fully initialized {@link AuthInstance}
10
+ * with session management, role assignment, impersonation, and magic link support.
11
+ *
12
+ * @param config - The auth configuration including permissions, authentication methods, and role rules.
13
+ * @returns A factory function `(env: AuthEnvConfig) => AuthInstance` to call per-request.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { createAuth } from "@cfast/auth";
18
+ * import { permissions } from "./permissions";
19
+ *
20
+ * export const initAuth = createAuth({
21
+ * permissions,
22
+ * magicLink: {
23
+ * sendMagicLink: async ({ email, url }) => {
24
+ * await sendEmail({ to: email, html: `<a href="${url}">Sign in</a>` });
25
+ * },
26
+ * },
27
+ * redirects: { afterLogin: "/", loginPath: "/login" },
28
+ * });
29
+ *
30
+ * // Per-request initialization:
31
+ * const auth = initAuth({ d1: env.DB, appUrl: "https://myapp.com" });
32
+ * const ctx = await auth.requireUser(request);
33
+ * ```
34
+ */
35
+ declare function createAuth(config: AuthConfig): (env: AuthEnvConfig) => AuthInstance;
36
+
37
+ /**
38
+ * Options for configuring the role manager's storage and authorization rules.
39
+ */
40
+ type RoleManagerOptions = {
41
+ /** Custom table name for role storage. Defaults to `"roles"`. */
42
+ tableName?: string;
43
+ /** Maps each role to the roles it is allowed to assign. Used to enforce role grant rules. */
44
+ roleGrants?: Record<string, string[]>;
45
+ };
46
+ /** Options identifying the caller for role grant authorization checks. */
47
+ type CallerOptions = {
48
+ /** The roles of the user attempting the role assignment. */
49
+ callerRoles?: string[];
50
+ };
51
+ /**
52
+ * Creates a role manager backed by a Cloudflare D1 database.
53
+ *
54
+ * Provides methods to query, assign, replace, and remove user roles.
55
+ * When `roleGrants` is configured, assignment methods enforce authorization
56
+ * rules so that, for example, an editor cannot promote someone to admin.
57
+ *
58
+ * @param d1 - The Cloudflare D1 database binding.
59
+ * @param options - Optional configuration for table name and role grant rules.
60
+ * @returns An object with `getRoles`, `setRole`, `setRoles`, and `removeRole` methods.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * import { createRoleManager } from "@cfast/auth";
65
+ *
66
+ * const roles = createRoleManager(env.DB, {
67
+ * roleGrants: {
68
+ * admin: ["admin", "editor", "user"],
69
+ * editor: ["user"],
70
+ * },
71
+ * });
72
+ *
73
+ * const userRoles = await roles.getRoles(userId);
74
+ * await roles.setRole(userId, "editor", { callerRoles: ["admin"] });
75
+ * ```
76
+ */
77
+ declare function createRoleManager(d1: D1Database, options?: RoleManagerOptions): {
78
+ getRoles(userId: string): Promise<string[]>;
79
+ setRole(userId: string, role: string, caller?: CallerOptions): Promise<void>;
80
+ setRoles(userId: string, roles: string[], caller?: CallerOptions): Promise<void>;
81
+ removeRole(userId: string, role: string): Promise<void>;
82
+ };
83
+
84
+ /**
85
+ * Options for customizing the impersonation manager's D1 table and column names.
86
+ */
87
+ type ImpersonationManagerOptions = {
88
+ /** Custom table name for impersonation logs. Defaults to `"impersonation_logs"`. */
89
+ tableName?: string;
90
+ /** Column name for the admin user ID. Defaults to `"admin_id"`. */
91
+ adminIdColumn?: string;
92
+ /** Column name for the target (impersonated) user ID. Defaults to `"target_user_id"`. */
93
+ targetUserIdColumn?: string;
94
+ };
95
+ /**
96
+ * Creates an impersonation manager backed by a Cloudflare D1 database.
97
+ *
98
+ * Manages an audit trail of impersonation sessions, allowing admins to
99
+ * temporarily act as another user for debugging and support. Each session
100
+ * is logged with start and end timestamps.
101
+ *
102
+ * @param d1 - The Cloudflare D1 database binding.
103
+ * @param options - Optional configuration for table and column names.
104
+ * @returns An object with `impersonate`, `stopImpersonating`, and `getActiveImpersonation` methods.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * import { createImpersonationManager } from "@cfast/auth";
109
+ *
110
+ * const impersonation = createImpersonationManager(env.DB);
111
+ *
112
+ * // Start impersonating a user
113
+ * await impersonation.impersonate(adminUserId, targetUserId);
114
+ *
115
+ * // Check if admin is currently impersonating someone
116
+ * const active = await impersonation.getActiveImpersonation(adminUserId);
117
+ * // active?.targetUserId
118
+ *
119
+ * // Stop impersonating
120
+ * await impersonation.stopImpersonating(adminUserId);
121
+ * ```
122
+ */
123
+ declare function createImpersonationManager(d1: D1Database, options?: ImpersonationManagerOptions): {
124
+ impersonate(adminUserId: string, targetUserId: string): Promise<void>;
125
+ stopImpersonating(adminUserId: string): Promise<void>;
126
+ getActiveImpersonation(adminUserId: string): Promise<{
127
+ targetUserId: string;
128
+ } | null>;
129
+ };
130
+
131
+ /**
132
+ * Creates `loader` and `action` handlers for a React Router auth catch-all route.
133
+ *
134
+ * The returned handlers forward all requests to the Better Auth handler via
135
+ * the {@link AuthInstance} obtained from the `getAuth` callback. Use this in
136
+ * a splat route (e.g., `routes/auth.$.tsx`) to handle magic link callbacks,
137
+ * passkey endpoints, and other Better Auth API routes.
138
+ *
139
+ * @param getAuth - A factory function that returns a fully initialized {@link AuthInstance}.
140
+ * Called on every request so that the instance uses the correct per-request D1 binding.
141
+ * @returns An object with `loader` and `action` functions compatible with React Router route modules.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * // routes/auth.$.tsx
146
+ * import { createAuthRouteHandlers } from "@cfast/auth";
147
+ * import { initAuth } from "~/auth.setup.server";
148
+ * import { env } from "~/env";
149
+ *
150
+ * const { loader, action } = createAuthRouteHandlers(() => {
151
+ * const e = env.get();
152
+ * return initAuth({ d1: e.DB, appUrl: e.APP_URL });
153
+ * });
154
+ *
155
+ * export { loader, action };
156
+ * ```
157
+ */
158
+ declare function createAuthRouteHandlers(getAuth: () => AuthInstance): {
159
+ loader: ({ request }: {
160
+ request: Request;
161
+ }) => Promise<Response>;
162
+ action: ({ request }: {
163
+ request: Request;
164
+ }) => Promise<Response>;
165
+ };
166
+
167
+ export { AuthConfig, AuthEnvConfig, AuthInstance, createAuth, createAuthRouteHandlers, createImpersonationManager, createRoleManager };
package/dist/index.js ADDED
@@ -0,0 +1,260 @@
1
+ // src/create-auth.ts
2
+ import { betterAuth } from "better-auth";
3
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
+ import { magicLink } from "better-auth/plugins/magic-link";
5
+ import { drizzle } from "drizzle-orm/d1";
6
+ import { resolveGrants } from "@cfast/permissions";
7
+
8
+ // src/roles.ts
9
+ function checkRoleGrants(roleGrants, callerRoles, targetRole) {
10
+ const allowed = callerRoles.some((callerRole) => {
11
+ const permitted = roleGrants[callerRole];
12
+ return permitted !== void 0 && permitted.includes(targetRole);
13
+ });
14
+ if (!allowed) {
15
+ throw new Error(
16
+ `Not authorized to assign role "${targetRole}"`
17
+ );
18
+ }
19
+ }
20
+ function createRoleManager(d1, options) {
21
+ const table = options?.tableName ?? "roles";
22
+ const roleGrants = options?.roleGrants;
23
+ return {
24
+ async getRoles(userId) {
25
+ const stmt = d1.prepare(
26
+ `SELECT role FROM ${table} WHERE user_id = ?`
27
+ );
28
+ const result = await stmt.bind(userId).all();
29
+ return result.results.map((r) => r.role);
30
+ },
31
+ async setRole(userId, role, caller) {
32
+ if (roleGrants && caller?.callerRoles) {
33
+ checkRoleGrants(roleGrants, caller.callerRoles, role);
34
+ }
35
+ const stmt = d1.prepare(
36
+ `INSERT OR IGNORE INTO ${table} (user_id, role) VALUES (?, ?)`
37
+ );
38
+ await stmt.bind(userId, role).run();
39
+ },
40
+ async setRoles(userId, roles, caller) {
41
+ if (roleGrants && caller?.callerRoles) {
42
+ for (const role of roles) {
43
+ checkRoleGrants(roleGrants, caller.callerRoles, role);
44
+ }
45
+ }
46
+ const deleteStmt = d1.prepare(`DELETE FROM ${table} WHERE user_id = ?`).bind(userId);
47
+ if (roles.length === 0) {
48
+ await deleteStmt.run();
49
+ return;
50
+ }
51
+ const insertStmts = roles.map(
52
+ (role) => d1.prepare(`INSERT INTO ${table} (user_id, role) VALUES (?, ?)`).bind(userId, role)
53
+ );
54
+ await d1.batch([deleteStmt, ...insertStmts]);
55
+ },
56
+ async removeRole(userId, role) {
57
+ const stmt = d1.prepare(
58
+ `DELETE FROM ${table} WHERE user_id = ? AND role = ?`
59
+ );
60
+ await stmt.bind(userId, role).run();
61
+ }
62
+ };
63
+ }
64
+
65
+ // src/impersonation.ts
66
+ function createImpersonationManager(d1, options) {
67
+ const table = options?.tableName ?? "impersonation_logs";
68
+ const adminCol = options?.adminIdColumn ?? "admin_id";
69
+ const targetCol = options?.targetUserIdColumn ?? "target_user_id";
70
+ return {
71
+ async impersonate(adminUserId, targetUserId) {
72
+ const id = crypto.randomUUID();
73
+ const now = Date.now();
74
+ await d1.prepare(
75
+ `INSERT INTO ${table} (id, ${adminCol}, ${targetCol}, started_at) VALUES (?, ?, ?, ?)`
76
+ ).bind(id, adminUserId, targetUserId, now).run();
77
+ },
78
+ async stopImpersonating(adminUserId) {
79
+ const now = Date.now();
80
+ await d1.prepare(
81
+ `UPDATE ${table} SET ended_at = ? WHERE ${adminCol} = ? AND ended_at IS NULL`
82
+ ).bind(now, adminUserId).run();
83
+ },
84
+ async getActiveImpersonation(adminUserId) {
85
+ const result = await d1.prepare(
86
+ `SELECT ${targetCol} as target_user_id FROM ${table} WHERE ${adminCol} = ? AND ended_at IS NULL LIMIT 1`
87
+ ).bind(adminUserId).first();
88
+ return result ? { targetUserId: result.target_user_id } : null;
89
+ }
90
+ };
91
+ }
92
+
93
+ // src/create-auth.ts
94
+ function parseExpiresIn(value) {
95
+ const match = value.match(/^(\d+)(s|m|h|d)$/);
96
+ if (!match) return 60 * 60 * 24 * 30;
97
+ const num = parseInt(match[1], 10);
98
+ switch (match[2]) {
99
+ case "s":
100
+ return num;
101
+ case "m":
102
+ return num * 60;
103
+ case "h":
104
+ return num * 3600;
105
+ case "d":
106
+ return num * 86400;
107
+ default:
108
+ return num;
109
+ }
110
+ }
111
+ function createAuth(config) {
112
+ const anonymousRoles = config.anonymousRoles ?? [];
113
+ const anonymousGrants = resolveGrants(
114
+ config.permissions,
115
+ anonymousRoles
116
+ );
117
+ const loginPath = config.redirects?.loginPath ?? "/login";
118
+ return function initAuth(env) {
119
+ const roleManager = createRoleManager(env.d1, {
120
+ tableName: config.roleTableName,
121
+ roleGrants: config.roleGrants
122
+ });
123
+ const impersonationManager = createImpersonationManager(env.d1);
124
+ const plugins = [];
125
+ if (config.magicLink) {
126
+ plugins.push(
127
+ magicLink({ sendMagicLink: config.magicLink.sendMagicLink })
128
+ );
129
+ }
130
+ const expiresIn = config.session?.expiresIn ? parseExpiresIn(config.session.expiresIn) : void 0;
131
+ const auth = betterAuth({
132
+ baseURL: env.appUrl,
133
+ database: drizzleAdapter(drizzle(env.d1), {
134
+ provider: "sqlite",
135
+ usePlural: true,
136
+ ...config.schema ? { schema: config.schema } : {}
137
+ }),
138
+ emailAndPassword: { enabled: true },
139
+ plugins,
140
+ ...expiresIn !== void 0 ? { session: { expiresIn } } : {}
141
+ });
142
+ async function createContext(request) {
143
+ try {
144
+ const sessionResult = await auth.api.getSession({
145
+ headers: request.headers
146
+ });
147
+ if (!sessionResult) {
148
+ return { user: null, grants: anonymousGrants };
149
+ }
150
+ const { user, session: _session } = sessionResult;
151
+ const impersonation = await impersonationManager.getActiveImpersonation(user.id);
152
+ if (impersonation) {
153
+ let targetRoles = await roleManager.getRoles(
154
+ impersonation.targetUserId
155
+ );
156
+ if (targetRoles.length === 0) {
157
+ targetRoles = config.defaultRoles ?? ["reader"];
158
+ }
159
+ const grants2 = resolveGrants(config.permissions, targetRoles);
160
+ return {
161
+ user: {
162
+ id: impersonation.targetUserId,
163
+ email: user.email,
164
+ name: user.name ?? "",
165
+ avatarUrl: user.image ?? null,
166
+ roles: targetRoles,
167
+ isImpersonating: true,
168
+ realUser: { id: user.id, name: user.name ?? "" }
169
+ },
170
+ grants: grants2
171
+ };
172
+ }
173
+ let roles = await roleManager.getRoles(user.id);
174
+ if (roles.length === 0) {
175
+ roles = config.defaultRoles ?? ["reader"];
176
+ }
177
+ const grants = resolveGrants(config.permissions, roles);
178
+ return {
179
+ user: {
180
+ id: user.id,
181
+ email: user.email,
182
+ name: user.name ?? "",
183
+ avatarUrl: user.image ?? null,
184
+ roles
185
+ },
186
+ grants
187
+ };
188
+ } catch (err) {
189
+ console.error("[cfast/auth] createContext error:", err);
190
+ return { user: null, grants: anonymousGrants };
191
+ }
192
+ }
193
+ async function requireUser(request) {
194
+ const ctx = await createContext(request);
195
+ if (ctx.user === null) {
196
+ const redirectTo = new URL(request.url).pathname;
197
+ const headers = new Headers();
198
+ headers.set("Location", loginPath);
199
+ headers.set(
200
+ "Set-Cookie",
201
+ `cfast_redirect_to=${encodeURIComponent(redirectTo)}; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`
202
+ );
203
+ throw new Response(null, { status: 302, headers });
204
+ }
205
+ const { user, grants } = ctx;
206
+ return { user, grants };
207
+ }
208
+ const allowedImpersonationRoles = config.impersonation?.allowedRoles ?? ["admin"];
209
+ return {
210
+ createContext,
211
+ requireUser,
212
+ getRoles: roleManager.getRoles,
213
+ setRole: roleManager.setRole,
214
+ setRoles: roleManager.setRoles,
215
+ removeRole: roleManager.removeRole,
216
+ impersonate: async (adminUserId, targetUserId) => {
217
+ const adminRoles = await roleManager.getRoles(adminUserId);
218
+ const canImpersonate = adminRoles.some(
219
+ (r) => allowedImpersonationRoles.includes(r)
220
+ );
221
+ if (!canImpersonate) {
222
+ throw new Error("Not authorized to impersonate");
223
+ }
224
+ await impersonationManager.impersonate(adminUserId, targetUserId);
225
+ },
226
+ stopImpersonating: impersonationManager.stopImpersonating,
227
+ sendMagicLink: async (params) => {
228
+ if (!config.magicLink) {
229
+ throw new Error(
230
+ "Magic link plugin not configured. Add magicLink to createAuth config."
231
+ );
232
+ }
233
+ const magicLinkApi = auth.api;
234
+ await magicLinkApi.signInMagicLink({
235
+ body: {
236
+ email: params.email,
237
+ callbackURL: params.callbackURL ?? config.redirects?.afterLogin ?? "/"
238
+ },
239
+ headers: new Headers()
240
+ });
241
+ },
242
+ handler: (request) => auth.handler(request),
243
+ api: auth
244
+ };
245
+ };
246
+ }
247
+
248
+ // src/route-handlers.ts
249
+ function createAuthRouteHandlers(getAuth) {
250
+ function handleRequest({ request }) {
251
+ return getAuth().handler(request);
252
+ }
253
+ return { loader: handleRequest, action: handleRequest };
254
+ }
255
+ export {
256
+ createAuth,
257
+ createAuthRouteHandlers,
258
+ createImpersonationManager,
259
+ createRoleManager
260
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Route helper for React Router v7's routes.ts.
3
+ *
4
+ * Generates a catch-all route entry for Better Auth's API endpoints.
5
+ * The consumer must create a handler file that uses `createAuthRouteHandlers`.
6
+ *
7
+ * Usage in `app/routes.ts`:
8
+ * ```ts
9
+ * import type { RouteConfig } from "@react-router/dev/routes";
10
+ * import { authRoutes } from "@cfast/auth/plugin";
11
+ *
12
+ * export default [
13
+ * ...authRoutes({ handlerFile: "routes/auth.$.tsx" }),
14
+ * // ... other routes
15
+ * ] satisfies RouteConfig;
16
+ * ```
17
+ *
18
+ * The handler file (`routes/auth.$.tsx`):
19
+ * ```ts
20
+ * import { createAuthRouteHandlers } from "@cfast/auth";
21
+ * const { loader, action } = createAuthRouteHandlers(() => getAuth());
22
+ * export { loader, action };
23
+ * ```
24
+ */
25
+ type RouteConfigEntry = {
26
+ id?: string;
27
+ path?: string;
28
+ index?: boolean;
29
+ caseSensitive?: boolean;
30
+ file: string;
31
+ children?: RouteConfigEntry[];
32
+ };
33
+ type AuthRoutesOptions = {
34
+ /** Path to the auth handler file, relative to appDirectory */
35
+ handlerFile: string;
36
+ /** Base path for auth routes. Default: "auth" */
37
+ basePath?: string;
38
+ };
39
+ declare function authRoutes(options: AuthRoutesOptions): RouteConfigEntry[];
40
+
41
+ export { authRoutes };