@wopr-network/platform-core 1.0.1 → 1.0.3

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.
@@ -11,13 +11,60 @@ import { betterAuth } from "better-auth";
11
11
  import type { Pool } from "pg";
12
12
  import type { PlatformDb } from "../db/index.js";
13
13
  import { PgEmailVerifier } from "../email/verification.js";
14
+ import { type IUserCreator } from "./user-creator.js";
15
+ /** OAuth provider credentials. */
16
+ export interface OAuthProvider {
17
+ clientId: string;
18
+ clientSecret: string;
19
+ }
20
+ /** Rate limit rule for a specific auth endpoint. */
21
+ export interface AuthRateLimitRule {
22
+ window: number;
23
+ max: number;
24
+ }
14
25
  /** Configuration for initializing Better Auth in platform-core. */
15
26
  export interface BetterAuthConfig {
16
27
  pool: Pool;
17
28
  db: PlatformDb;
29
+ /** HMAC secret for session tokens. Falls back to BETTER_AUTH_SECRET env var. */
30
+ secret?: string;
31
+ /** Base URL for OAuth callbacks. Falls back to BETTER_AUTH_URL env var. */
32
+ baseURL?: string;
33
+ /** Route prefix. Default: "/api/auth" */
34
+ basePath?: string;
35
+ /** Email+password config. Default: enabled with 12-char min. */
36
+ emailAndPassword?: {
37
+ enabled: boolean;
38
+ minPasswordLength?: number;
39
+ };
40
+ /** OAuth providers. Default: reads GITHUB/DISCORD/GOOGLE env vars. */
41
+ socialProviders?: {
42
+ github?: OAuthProvider;
43
+ discord?: OAuthProvider;
44
+ google?: OAuthProvider;
45
+ };
46
+ /** Trusted providers for account linking. Default: ["github", "google"] */
47
+ trustedProviders?: string[];
48
+ /** Enable 2FA plugin. Default: true */
49
+ twoFactor?: boolean;
50
+ /** Cookie cache max age in seconds. Default: 300 (5 min) */
51
+ sessionCacheMaxAge?: number;
52
+ /** Cookie prefix. Default: "better-auth" */
53
+ cookiePrefix?: string;
54
+ /** Cookie domain (e.g., ".wopr.bot"). Falls back to COOKIE_DOMAIN env var. */
55
+ cookieDomain?: string;
56
+ /** Global rate limit window in seconds. Default: 60 */
57
+ rateLimitWindow?: number;
58
+ /** Global rate limit max requests. Default: 100 */
59
+ rateLimitMax?: number;
60
+ /** Per-endpoint rate limit overrides. Default: sign-in/sign-up/reset limits. */
61
+ rateLimitRules?: Record<string, AuthRateLimitRule>;
62
+ /** Trusted origins for CORS. Falls back to UI_ORIGIN env var. */
63
+ trustedOrigins?: string[];
18
64
  /** Called after a new user signs up (e.g., create personal tenant). */
19
65
  onUserCreated?: (userId: string, userName: string, email: string) => Promise<void>;
20
66
  }
67
+ export declare function getUserCreator(): Promise<IUserCreator>;
21
68
  /** The type of a better-auth instance. */
22
69
  export type Auth = ReturnType<typeof betterAuth>;
23
70
  /** Initialize Better Auth with the given config. Must be called before getAuth(). */
@@ -16,50 +16,83 @@ import { getEmailClient } from "../email/client.js";
16
16
  import { passwordResetEmailTemplate, verifyEmailTemplate } from "../email/templates.js";
17
17
  import { generateVerificationToken, initVerificationSchema, PgEmailVerifier } from "../email/verification.js";
18
18
  import { createUserCreator } from "./user-creator.js";
19
- const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET || "";
20
- const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL || "http://localhost:3100";
19
+ const DEFAULT_RATE_LIMIT_RULES = {
20
+ "/sign-in/email": { window: 900, max: 5 },
21
+ "/sign-up/email": { window: 3600, max: 10 },
22
+ "/request-password-reset": { window: 3600, max: 3 },
23
+ };
21
24
  let _config = null;
22
25
  let _userCreator = null;
23
26
  let _userCreatorPromise = null;
24
- async function getUserCreator() {
27
+ export async function getUserCreator() {
25
28
  if (_userCreator)
26
29
  return _userCreator;
27
30
  if (!_userCreatorPromise) {
28
31
  if (!_config)
29
32
  throw new Error("BetterAuth not initialized — call initBetterAuth() first");
30
- _userCreatorPromise = createUserCreator(new RoleStore(_config.db)).then((creator) => {
33
+ _userCreatorPromise = createUserCreator(new RoleStore(_config.db))
34
+ .then((creator) => {
31
35
  _userCreator = creator;
32
36
  return creator;
37
+ })
38
+ .catch((err) => {
39
+ _userCreatorPromise = null;
40
+ throw err;
33
41
  });
34
42
  }
35
43
  return _userCreatorPromise;
36
44
  }
37
- function authOptions(pool) {
45
+ /** Resolve OAuth providers from config or env vars. */
46
+ function resolveSocialProviders(cfg) {
47
+ if (cfg.socialProviders)
48
+ return cfg.socialProviders;
49
+ return {
50
+ ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
51
+ ? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } }
52
+ : {}),
53
+ ...(process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
54
+ ? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
55
+ : {}),
56
+ ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
57
+ ? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
58
+ : {}),
59
+ };
60
+ }
61
+ function authOptions(cfg) {
62
+ const pool = cfg.pool;
63
+ const secret = cfg.secret || process.env.BETTER_AUTH_SECRET;
64
+ if (!secret) {
65
+ if (process.env.NODE_ENV === "production") {
66
+ throw new Error("BETTER_AUTH_SECRET is required in production");
67
+ }
68
+ logger.warn("BetterAuth secret not configured — sessions may be insecure");
69
+ }
70
+ const baseURL = cfg.baseURL || process.env.BETTER_AUTH_URL || "http://localhost:3100";
71
+ const basePath = cfg.basePath || "/api/auth";
72
+ const cookieDomain = cfg.cookieDomain || process.env.COOKIE_DOMAIN;
73
+ const trustedOrigins = cfg.trustedOrigins ||
74
+ (process.env.UI_ORIGIN || "http://localhost:3001")
75
+ .split(",")
76
+ .map((o) => o.trim())
77
+ .filter(Boolean);
78
+ // Default minPasswordLength: 12 — caller must explicitly override, not accidentally omit
79
+ const emailAndPassword = cfg.emailAndPassword
80
+ ? { minPasswordLength: 12, ...cfg.emailAndPassword }
81
+ : { enabled: true, minPasswordLength: 12 };
38
82
  return {
39
83
  database: pool,
40
- secret: BETTER_AUTH_SECRET,
41
- baseURL: BETTER_AUTH_URL,
42
- basePath: "/api/auth",
43
- socialProviders: {
44
- ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
45
- ? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } }
46
- : {}),
47
- ...(process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
48
- ? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
49
- : {}),
50
- ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
51
- ? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
52
- : {}),
53
- },
84
+ secret: secret || "",
85
+ baseURL,
86
+ basePath,
87
+ socialProviders: resolveSocialProviders(cfg),
54
88
  account: {
55
89
  accountLinking: {
56
90
  enabled: true,
57
- trustedProviders: ["github", "google"],
91
+ trustedProviders: cfg.trustedProviders ?? ["github", "google"],
58
92
  },
59
93
  },
60
94
  emailAndPassword: {
61
- enabled: true,
62
- minPasswordLength: 12,
95
+ ...emailAndPassword,
63
96
  sendResetPassword: async ({ user, url }) => {
64
97
  try {
65
98
  const emailClient = getEmailClient();
@@ -87,12 +120,20 @@ function authOptions(pool) {
87
120
  catch (error) {
88
121
  logger.error("Failed to run user creator:", error);
89
122
  }
123
+ if (cfg.onUserCreated) {
124
+ try {
125
+ await cfg.onUserCreated(user.id, user.name || user.email, user.email);
126
+ }
127
+ catch (error) {
128
+ logger.error("Failed to run onUserCreated callback:", error);
129
+ }
130
+ }
90
131
  if (user.emailVerified)
91
132
  return;
92
133
  try {
93
134
  await initVerificationSchema(pool);
94
135
  const { token } = await generateVerificationToken(pool, user.id);
95
- const verifyUrl = `${BETTER_AUTH_URL}/auth/verify?token=${token}`;
136
+ const verifyUrl = `${baseURL}${basePath}/verify?token=${token}`;
96
137
  const emailClient = getEmailClient();
97
138
  const template = verifyEmailTemplate(verifyUrl, user.email);
98
139
  await emailClient.send({
@@ -105,45 +146,30 @@ function authOptions(pool) {
105
146
  catch (error) {
106
147
  logger.error("Failed to send verification email:", error);
107
148
  }
108
- // Delegate personal tenant creation to the consumer
109
- if (_config?.onUserCreated) {
110
- try {
111
- await _config.onUserCreated(user.id, user.name || user.email, user.email);
112
- }
113
- catch (error) {
114
- logger.error("Failed to run onUserCreated callback:", error);
115
- }
116
- }
117
149
  },
118
150
  },
119
151
  },
120
152
  },
121
153
  session: {
122
- cookieCache: { enabled: true, maxAge: 5 * 60 },
154
+ cookieCache: { enabled: true, maxAge: cfg.sessionCacheMaxAge ?? 300 },
123
155
  },
124
156
  advanced: {
125
- cookiePrefix: "better-auth",
157
+ cookiePrefix: cfg.cookiePrefix || "better-auth",
126
158
  cookies: {
127
159
  session_token: {
128
- attributes: {
129
- domain: process.env.COOKIE_DOMAIN || ".wopr.bot",
130
- },
160
+ attributes: cookieDomain ? { domain: cookieDomain } : {},
131
161
  },
132
162
  },
133
163
  },
134
- plugins: [twoFactor()],
164
+ plugins: cfg.twoFactor !== false ? [twoFactor()] : [],
135
165
  rateLimit: {
136
166
  enabled: true,
137
- window: 60,
138
- max: 100,
139
- customRules: {
140
- "/sign-in/email": { window: 900, max: 5 },
141
- "/sign-up/email": { window: 3600, max: 10 },
142
- "/request-password-reset": { window: 3600, max: 3 },
143
- },
167
+ window: cfg.rateLimitWindow ?? 60,
168
+ max: cfg.rateLimitMax ?? 100,
169
+ customRules: { ...DEFAULT_RATE_LIMIT_RULES, ...cfg.rateLimitRules },
144
170
  storage: "memory",
145
171
  },
146
- trustedOrigins: (process.env.UI_ORIGIN || "http://localhost:3001").split(","),
172
+ trustedOrigins,
147
173
  };
148
174
  }
149
175
  /** Initialize Better Auth with the given config. Must be called before getAuth(). */
@@ -158,9 +184,11 @@ export async function runAuthMigrations() {
158
184
  if (!_config)
159
185
  throw new Error("BetterAuth not initialized — call initBetterAuth() first");
160
186
  const { getMigrations } = (await import("better-auth/db"));
161
- const { runMigrations } = await getMigrations(authOptions(_config.pool));
187
+ const { runMigrations } = await getMigrations(authOptions(_config));
162
188
  await runMigrations();
163
- await initTwoFactorSchema(_config.pool);
189
+ if (_config.twoFactor !== false) {
190
+ await initTwoFactorSchema(_config.pool);
191
+ }
164
192
  }
165
193
  let _auth = null;
166
194
  /**
@@ -171,7 +199,7 @@ export function getAuth() {
171
199
  if (!_auth) {
172
200
  if (!_config)
173
201
  throw new Error("BetterAuth not initialized — call initBetterAuth() first");
174
- _auth = betterAuth(authOptions(_config.pool));
202
+ _auth = betterAuth(authOptions(_config));
175
203
  }
176
204
  return _auth;
177
205
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ const mockCreateUserCreator = vi.fn();
3
+ vi.mock("./user-creator.js", () => ({
4
+ createUserCreator: (...args) => mockCreateUserCreator(...args),
5
+ }));
6
+ vi.mock("../admin/role-store.js", () => ({
7
+ RoleStore: vi.fn(),
8
+ }));
9
+ const { getUserCreator, initBetterAuth, resetUserCreator } = await import("./better-auth.js");
10
+ const fakeConfig = { pool: {}, db: {} };
11
+ describe("getUserCreator", () => {
12
+ afterEach(() => {
13
+ resetUserCreator();
14
+ mockCreateUserCreator.mockReset();
15
+ });
16
+ it("caches the resolved creator on success", async () => {
17
+ initBetterAuth(fakeConfig);
18
+ const fakeCreator = { createUser: vi.fn() };
19
+ mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
20
+ const first = await getUserCreator();
21
+ const second = await getUserCreator();
22
+ expect(first).toBe(second);
23
+ expect(mockCreateUserCreator).toHaveBeenCalledOnce();
24
+ });
25
+ it("clears cached promise on rejection so next call retries", async () => {
26
+ initBetterAuth(fakeConfig);
27
+ const fakeCreator = { createUser: vi.fn() };
28
+ mockCreateUserCreator.mockRejectedValueOnce(new Error("DB unavailable"));
29
+ mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
30
+ await expect(getUserCreator()).rejects.toThrow("DB unavailable");
31
+ const creator = await getUserCreator();
32
+ expect(creator).toBe(fakeCreator);
33
+ expect(mockCreateUserCreator).toHaveBeenCalledTimes(2);
34
+ });
35
+ });
@@ -184,3 +184,15 @@ export declare function validateTenantOwnership<T>(c: Context, resource: T | nul
184
184
  * @returns true if the user has access, false otherwise.
185
185
  */
186
186
  export declare function validateTenantAccess(userId: string, requestedTenantId: string | undefined, orgMemberRepo: IOrgMemberRepository): Promise<boolean>;
187
+ export type { IApiKeyRepository } from "./api-key-repository.js";
188
+ export { DrizzleApiKeyRepository } from "./api-key-repository.js";
189
+ export type { Auth, AuthRateLimitRule, BetterAuthConfig, OAuthProvider, } from "./better-auth.js";
190
+ export { getAuth, getEmailVerifier, getUserCreator, initBetterAuth, resetAuth, resetUserCreator, runAuthMigrations, setAuth, } from "./better-auth.js";
191
+ export type { ILoginHistoryRepository, LoginHistoryEntry } from "./login-history-repository.js";
192
+ export { BetterAuthLoginHistoryRepository } from "./login-history-repository.js";
193
+ export type { SessionAuthEnv } from "./middleware.js";
194
+ export { dualAuth, sessionAuth } from "./middleware.js";
195
+ export type { IUserCreator } from "./user-creator.js";
196
+ export { createUserCreator } from "./user-creator.js";
197
+ export type { IUserRoleRepository } from "./user-role-repository.js";
198
+ export { DrizzleUserRoleRepository } from "./user-role-repository.js";
@@ -420,3 +420,10 @@ export async function validateTenantAccess(userId, requestedTenantId, orgMemberR
420
420
  const member = await orgMemberRepo.findMember(requestedTenantId, userId);
421
421
  return member !== null;
422
422
  }
423
+ export { DrizzleApiKeyRepository } from "./api-key-repository.js";
424
+ // Test utilities — do not call in production code
425
+ export { getAuth, getEmailVerifier, getUserCreator, initBetterAuth, resetAuth, resetUserCreator, runAuthMigrations, setAuth, } from "./better-auth.js";
426
+ export { BetterAuthLoginHistoryRepository } from "./login-history-repository.js";
427
+ export { dualAuth, sessionAuth } from "./middleware.js";
428
+ export { createUserCreator } from "./user-creator.js";
429
+ export { DrizzleUserRoleRepository } from "./user-role-repository.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,47 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockCreateUserCreator = vi.fn();
4
+ vi.mock("./user-creator.js", () => ({
5
+ createUserCreator: (...args: unknown[]) => mockCreateUserCreator(...args),
6
+ }));
7
+
8
+ vi.mock("../admin/role-store.js", () => ({
9
+ RoleStore: vi.fn(),
10
+ }));
11
+
12
+ const { getUserCreator, initBetterAuth, resetUserCreator } = await import("./better-auth.js");
13
+
14
+ const fakeConfig = { pool: {}, db: {} } as Parameters<typeof initBetterAuth>[0];
15
+
16
+ describe("getUserCreator", () => {
17
+ afterEach(() => {
18
+ resetUserCreator();
19
+ mockCreateUserCreator.mockReset();
20
+ });
21
+
22
+ it("caches the resolved creator on success", async () => {
23
+ initBetterAuth(fakeConfig);
24
+ const fakeCreator = { createUser: vi.fn() };
25
+ mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
26
+
27
+ const first = await getUserCreator();
28
+ const second = await getUserCreator();
29
+
30
+ expect(first).toBe(second);
31
+ expect(mockCreateUserCreator).toHaveBeenCalledOnce();
32
+ });
33
+
34
+ it("clears cached promise on rejection so next call retries", async () => {
35
+ initBetterAuth(fakeConfig);
36
+ const fakeCreator = { createUser: vi.fn() };
37
+
38
+ mockCreateUserCreator.mockRejectedValueOnce(new Error("DB unavailable"));
39
+ mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
40
+
41
+ await expect(getUserCreator()).rejects.toThrow("DB unavailable");
42
+
43
+ const creator = await getUserCreator();
44
+ expect(creator).toBe(fakeCreator);
45
+ expect(mockCreateUserCreator).toHaveBeenCalledTimes(2);
46
+ });
47
+ });
@@ -20,59 +20,150 @@ import { passwordResetEmailTemplate, verifyEmailTemplate } from "../email/templa
20
20
  import { generateVerificationToken, initVerificationSchema, PgEmailVerifier } from "../email/verification.js";
21
21
  import { createUserCreator, type IUserCreator } from "./user-creator.js";
22
22
 
23
+ /** OAuth provider credentials. */
24
+ export interface OAuthProvider {
25
+ clientId: string;
26
+ clientSecret: string;
27
+ }
28
+
29
+ /** Rate limit rule for a specific auth endpoint. */
30
+ export interface AuthRateLimitRule {
31
+ window: number;
32
+ max: number;
33
+ }
34
+
23
35
  /** Configuration for initializing Better Auth in platform-core. */
24
36
  export interface BetterAuthConfig {
25
37
  pool: Pool;
26
38
  db: PlatformDb;
39
+
40
+ // --- Required ---
41
+ /** HMAC secret for session tokens. Falls back to BETTER_AUTH_SECRET env var. */
42
+ secret?: string;
43
+ /** Base URL for OAuth callbacks. Falls back to BETTER_AUTH_URL env var. */
44
+ baseURL?: string;
45
+
46
+ // --- Auth features ---
47
+ /** Route prefix. Default: "/api/auth" */
48
+ basePath?: string;
49
+ /** Email+password config. Default: enabled with 12-char min. */
50
+ emailAndPassword?: { enabled: boolean; minPasswordLength?: number };
51
+ /** OAuth providers. Default: reads GITHUB/DISCORD/GOOGLE env vars. */
52
+ socialProviders?: {
53
+ github?: OAuthProvider;
54
+ discord?: OAuthProvider;
55
+ google?: OAuthProvider;
56
+ };
57
+ /** Trusted providers for account linking. Default: ["github", "google"] */
58
+ trustedProviders?: string[];
59
+ /** Enable 2FA plugin. Default: true */
60
+ twoFactor?: boolean;
61
+
62
+ // --- Session & cookies ---
63
+ /** Cookie cache max age in seconds. Default: 300 (5 min) */
64
+ sessionCacheMaxAge?: number;
65
+ /** Cookie prefix. Default: "better-auth" */
66
+ cookiePrefix?: string;
67
+ /** Cookie domain (e.g., ".wopr.bot"). Falls back to COOKIE_DOMAIN env var. */
68
+ cookieDomain?: string;
69
+
70
+ // --- Rate limiting ---
71
+ /** Global rate limit window in seconds. Default: 60 */
72
+ rateLimitWindow?: number;
73
+ /** Global rate limit max requests. Default: 100 */
74
+ rateLimitMax?: number;
75
+ /** Per-endpoint rate limit overrides. Default: sign-in/sign-up/reset limits. */
76
+ rateLimitRules?: Record<string, AuthRateLimitRule>;
77
+
78
+ // --- Origins ---
79
+ /** Trusted origins for CORS. Falls back to UI_ORIGIN env var. */
80
+ trustedOrigins?: string[];
81
+
82
+ // --- Lifecycle hooks ---
27
83
  /** Called after a new user signs up (e.g., create personal tenant). */
28
84
  onUserCreated?: (userId: string, userName: string, email: string) => Promise<void>;
29
85
  }
30
86
 
31
- const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET || "";
32
- const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL || "http://localhost:3100";
87
+ const DEFAULT_RATE_LIMIT_RULES: Record<string, AuthRateLimitRule> = {
88
+ "/sign-in/email": { window: 900, max: 5 },
89
+ "/sign-up/email": { window: 3600, max: 10 },
90
+ "/request-password-reset": { window: 3600, max: 3 },
91
+ };
33
92
 
34
93
  let _config: BetterAuthConfig | null = null;
35
94
  let _userCreator: IUserCreator | null = null;
36
95
  let _userCreatorPromise: Promise<IUserCreator> | null = null;
37
96
 
38
- async function getUserCreator(): Promise<IUserCreator> {
97
+ export async function getUserCreator(): Promise<IUserCreator> {
39
98
  if (_userCreator) return _userCreator;
40
99
  if (!_userCreatorPromise) {
41
100
  if (!_config) throw new Error("BetterAuth not initialized — call initBetterAuth() first");
42
- _userCreatorPromise = createUserCreator(new RoleStore(_config.db)).then((creator) => {
43
- _userCreator = creator;
44
- return creator;
45
- });
101
+ _userCreatorPromise = createUserCreator(new RoleStore(_config.db))
102
+ .then((creator) => {
103
+ _userCreator = creator;
104
+ return creator;
105
+ })
106
+ .catch((err) => {
107
+ _userCreatorPromise = null;
108
+ throw err;
109
+ });
46
110
  }
47
111
  return _userCreatorPromise;
48
112
  }
49
113
 
50
- function authOptions(pool: Pool): BetterAuthOptions {
114
+ /** Resolve OAuth providers from config or env vars. */
115
+ function resolveSocialProviders(cfg: BetterAuthConfig): BetterAuthOptions["socialProviders"] {
116
+ if (cfg.socialProviders) return cfg.socialProviders;
117
+ return {
118
+ ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
119
+ ? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } }
120
+ : {}),
121
+ ...(process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
122
+ ? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
123
+ : {}),
124
+ ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
125
+ ? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
126
+ : {}),
127
+ };
128
+ }
129
+
130
+ function authOptions(cfg: BetterAuthConfig): BetterAuthOptions {
131
+ const pool = cfg.pool;
132
+ const secret = cfg.secret || process.env.BETTER_AUTH_SECRET;
133
+ if (!secret) {
134
+ if (process.env.NODE_ENV === "production") {
135
+ throw new Error("BETTER_AUTH_SECRET is required in production");
136
+ }
137
+ logger.warn("BetterAuth secret not configured — sessions may be insecure");
138
+ }
139
+ const baseURL = cfg.baseURL || process.env.BETTER_AUTH_URL || "http://localhost:3100";
140
+ const basePath = cfg.basePath || "/api/auth";
141
+ const cookieDomain = cfg.cookieDomain || process.env.COOKIE_DOMAIN;
142
+ const trustedOrigins =
143
+ cfg.trustedOrigins ||
144
+ (process.env.UI_ORIGIN || "http://localhost:3001")
145
+ .split(",")
146
+ .map((o) => o.trim())
147
+ .filter(Boolean);
148
+ // Default minPasswordLength: 12 — caller must explicitly override, not accidentally omit
149
+ const emailAndPassword = cfg.emailAndPassword
150
+ ? { minPasswordLength: 12, ...cfg.emailAndPassword }
151
+ : { enabled: true, minPasswordLength: 12 };
152
+
51
153
  return {
52
154
  database: pool,
53
- secret: BETTER_AUTH_SECRET,
54
- baseURL: BETTER_AUTH_URL,
55
- basePath: "/api/auth",
56
- socialProviders: {
57
- ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
58
- ? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } }
59
- : {}),
60
- ...(process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
61
- ? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
62
- : {}),
63
- ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
64
- ? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
65
- : {}),
66
- },
155
+ secret: secret || "",
156
+ baseURL,
157
+ basePath,
158
+ socialProviders: resolveSocialProviders(cfg),
67
159
  account: {
68
160
  accountLinking: {
69
161
  enabled: true,
70
- trustedProviders: ["github", "google"],
162
+ trustedProviders: cfg.trustedProviders ?? ["github", "google"],
71
163
  },
72
164
  },
73
165
  emailAndPassword: {
74
- enabled: true,
75
- minPasswordLength: 12,
166
+ ...emailAndPassword,
76
167
  sendResetPassword: async ({ user, url }) => {
77
168
  try {
78
169
  const emailClient = getEmailClient();
@@ -99,12 +190,20 @@ function authOptions(pool: Pool): BetterAuthOptions {
99
190
  logger.error("Failed to run user creator:", error);
100
191
  }
101
192
 
193
+ if (cfg.onUserCreated) {
194
+ try {
195
+ await cfg.onUserCreated(user.id, user.name || user.email, user.email);
196
+ } catch (error) {
197
+ logger.error("Failed to run onUserCreated callback:", error);
198
+ }
199
+ }
200
+
102
201
  if (user.emailVerified) return;
103
202
 
104
203
  try {
105
204
  await initVerificationSchema(pool);
106
205
  const { token } = await generateVerificationToken(pool, user.id);
107
- const verifyUrl = `${BETTER_AUTH_URL}/auth/verify?token=${token}`;
206
+ const verifyUrl = `${baseURL}${basePath}/verify?token=${token}`;
108
207
  const emailClient = getEmailClient();
109
208
  const template = verifyEmailTemplate(verifyUrl, user.email);
110
209
  await emailClient.send({
@@ -116,45 +215,30 @@ function authOptions(pool: Pool): BetterAuthOptions {
116
215
  } catch (error) {
117
216
  logger.error("Failed to send verification email:", error);
118
217
  }
119
-
120
- // Delegate personal tenant creation to the consumer
121
- if (_config?.onUserCreated) {
122
- try {
123
- await _config.onUserCreated(user.id, user.name || user.email, user.email);
124
- } catch (error) {
125
- logger.error("Failed to run onUserCreated callback:", error);
126
- }
127
- }
128
218
  },
129
219
  },
130
220
  },
131
221
  },
132
222
  session: {
133
- cookieCache: { enabled: true, maxAge: 5 * 60 },
223
+ cookieCache: { enabled: true, maxAge: cfg.sessionCacheMaxAge ?? 300 },
134
224
  },
135
225
  advanced: {
136
- cookiePrefix: "better-auth",
226
+ cookiePrefix: cfg.cookiePrefix || "better-auth",
137
227
  cookies: {
138
228
  session_token: {
139
- attributes: {
140
- domain: process.env.COOKIE_DOMAIN || ".wopr.bot",
141
- },
229
+ attributes: cookieDomain ? { domain: cookieDomain } : {},
142
230
  },
143
231
  },
144
232
  },
145
- plugins: [twoFactor()],
233
+ plugins: cfg.twoFactor !== false ? [twoFactor()] : [],
146
234
  rateLimit: {
147
235
  enabled: true,
148
- window: 60,
149
- max: 100,
150
- customRules: {
151
- "/sign-in/email": { window: 900, max: 5 },
152
- "/sign-up/email": { window: 3600, max: 10 },
153
- "/request-password-reset": { window: 3600, max: 3 },
154
- },
236
+ window: cfg.rateLimitWindow ?? 60,
237
+ max: cfg.rateLimitMax ?? 100,
238
+ customRules: { ...DEFAULT_RATE_LIMIT_RULES, ...cfg.rateLimitRules },
155
239
  storage: "memory",
156
240
  },
157
- trustedOrigins: (process.env.UI_ORIGIN || "http://localhost:3001").split(","),
241
+ trustedOrigins,
158
242
  };
159
243
  }
160
244
 
@@ -174,9 +258,11 @@ export async function runAuthMigrations(): Promise<void> {
174
258
  if (!_config) throw new Error("BetterAuth not initialized — call initBetterAuth() first");
175
259
  type DbModule = { getMigrations: (opts: BetterAuthOptions) => Promise<{ runMigrations: () => Promise<void> }> };
176
260
  const { getMigrations } = (await import("better-auth/db")) as unknown as DbModule;
177
- const { runMigrations } = await getMigrations(authOptions(_config.pool));
261
+ const { runMigrations } = await getMigrations(authOptions(_config));
178
262
  await runMigrations();
179
- await initTwoFactorSchema(_config.pool);
263
+ if (_config.twoFactor !== false) {
264
+ await initTwoFactorSchema(_config.pool);
265
+ }
180
266
  }
181
267
 
182
268
  let _auth: Auth | null = null;
@@ -188,7 +274,7 @@ let _auth: Auth | null = null;
188
274
  export function getAuth(): Auth {
189
275
  if (!_auth) {
190
276
  if (!_config) throw new Error("BetterAuth not initialized — call initBetterAuth() first");
191
- _auth = betterAuth(authOptions(_config.pool));
277
+ _auth = betterAuth(authOptions(_config));
192
278
  }
193
279
  return _auth;
194
280
  }
package/src/auth/index.ts CHANGED
@@ -518,3 +518,35 @@ export async function validateTenantAccess(
518
518
  const member = await orgMemberRepo.findMember(requestedTenantId, userId);
519
519
  return member !== null;
520
520
  }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Re-exports — Better Auth factory, middleware, and repositories
524
+ // ---------------------------------------------------------------------------
525
+
526
+ export type { IApiKeyRepository } from "./api-key-repository.js";
527
+ export { DrizzleApiKeyRepository } from "./api-key-repository.js";
528
+ export type {
529
+ Auth,
530
+ AuthRateLimitRule,
531
+ BetterAuthConfig,
532
+ OAuthProvider,
533
+ } from "./better-auth.js";
534
+ // Test utilities — do not call in production code
535
+ export {
536
+ getAuth,
537
+ getEmailVerifier,
538
+ getUserCreator,
539
+ initBetterAuth,
540
+ resetAuth,
541
+ resetUserCreator,
542
+ runAuthMigrations,
543
+ setAuth,
544
+ } from "./better-auth.js";
545
+ export type { ILoginHistoryRepository, LoginHistoryEntry } from "./login-history-repository.js";
546
+ export { BetterAuthLoginHistoryRepository } from "./login-history-repository.js";
547
+ export type { SessionAuthEnv } from "./middleware.js";
548
+ export { dualAuth, sessionAuth } from "./middleware.js";
549
+ export type { IUserCreator } from "./user-creator.js";
550
+ export { createUserCreator } from "./user-creator.js";
551
+ export type { IUserRoleRepository } from "./user-role-repository.js";
552
+ export { DrizzleUserRoleRepository } from "./user-role-repository.js";
package/vitest.config.ts CHANGED
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
3
3
  export default defineConfig({
4
4
  test: {
5
5
  testTimeout: 30000,
6
+ hookTimeout: 30000,
6
7
  include: ["src/**/*.test.ts"],
7
8
  coverage: {
8
9
  provider: "v8",