@invect/user-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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/backend/index.cjs +1166 -0
  4. package/dist/backend/index.cjs.map +1 -0
  5. package/dist/backend/index.d.ts +42 -0
  6. package/dist/backend/index.d.ts.map +1 -0
  7. package/dist/backend/index.mjs +1164 -0
  8. package/dist/backend/index.mjs.map +1 -0
  9. package/dist/backend/plugin.d.ts +62 -0
  10. package/dist/backend/plugin.d.ts.map +1 -0
  11. package/dist/backend/types.d.ts +299 -0
  12. package/dist/backend/types.d.ts.map +1 -0
  13. package/dist/frontend/components/AuthGate.d.ts +17 -0
  14. package/dist/frontend/components/AuthGate.d.ts.map +1 -0
  15. package/dist/frontend/components/AuthenticatedInvect.d.ts +129 -0
  16. package/dist/frontend/components/AuthenticatedInvect.d.ts.map +1 -0
  17. package/dist/frontend/components/ProfilePage.d.ts +10 -0
  18. package/dist/frontend/components/ProfilePage.d.ts.map +1 -0
  19. package/dist/frontend/components/SidebarUserMenu.d.ts +12 -0
  20. package/dist/frontend/components/SidebarUserMenu.d.ts.map +1 -0
  21. package/dist/frontend/components/SignInForm.d.ts +14 -0
  22. package/dist/frontend/components/SignInForm.d.ts.map +1 -0
  23. package/dist/frontend/components/SignInPage.d.ts +19 -0
  24. package/dist/frontend/components/SignInPage.d.ts.map +1 -0
  25. package/dist/frontend/components/UserButton.d.ts +15 -0
  26. package/dist/frontend/components/UserButton.d.ts.map +1 -0
  27. package/dist/frontend/components/UserManagement.d.ts +19 -0
  28. package/dist/frontend/components/UserManagement.d.ts.map +1 -0
  29. package/dist/frontend/components/UserManagementPage.d.ts +9 -0
  30. package/dist/frontend/components/UserManagementPage.d.ts.map +1 -0
  31. package/dist/frontend/index.cjs +1262 -0
  32. package/dist/frontend/index.cjs.map +1 -0
  33. package/dist/frontend/index.d.ts +29 -0
  34. package/dist/frontend/index.d.ts.map +1 -0
  35. package/dist/frontend/index.mjs +1250 -0
  36. package/dist/frontend/index.mjs.map +1 -0
  37. package/dist/frontend/plugins/authFrontendPlugin.d.ts +14 -0
  38. package/dist/frontend/plugins/authFrontendPlugin.d.ts.map +1 -0
  39. package/dist/frontend/providers/AuthProvider.d.ts +46 -0
  40. package/dist/frontend/providers/AuthProvider.d.ts.map +1 -0
  41. package/dist/roles-BOY5N82v.cjs +74 -0
  42. package/dist/roles-BOY5N82v.cjs.map +1 -0
  43. package/dist/roles-CZuKFEpJ.mjs +33 -0
  44. package/dist/roles-CZuKFEpJ.mjs.map +1 -0
  45. package/dist/shared/roles.d.ts +11 -0
  46. package/dist/shared/roles.d.ts.map +1 -0
  47. package/dist/shared/types.cjs +0 -0
  48. package/dist/shared/types.d.ts +46 -0
  49. package/dist/shared/types.d.ts.map +1 -0
  50. package/dist/shared/types.mjs +1 -0
  51. package/package.json +116 -0
@@ -0,0 +1,1166 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_roles = require("../roles-BOY5N82v.cjs");
3
+ //#region src/backend/plugin.ts
4
+ const DEFAULT_PREFIX = "auth";
5
+ /**
6
+ * Default role mapping: keep admin/RBAC roles aligned and fall back to default.
7
+ */
8
+ function defaultMapRole(role) {
9
+ if (!role) return require_roles.AUTH_DEFAULT_ROLE;
10
+ if (role === "viewer" || role === "readonly") return "viewer";
11
+ if (require_roles.isAuthVisibleRole(role)) return role;
12
+ return require_roles.AUTH_DEFAULT_ROLE;
13
+ }
14
+ /**
15
+ * Default user → identity mapping.
16
+ */
17
+ function defaultMapUser(user, _session, mapRole) {
18
+ const resolvedRole = mapRole(user.role);
19
+ return {
20
+ id: user.id,
21
+ name: user.name ?? user.email ?? void 0,
22
+ role: resolvedRole,
23
+ permissions: resolvedRole === "admin" ? ["admin:*"] : void 0,
24
+ resourceAccess: resolvedRole === "admin" ? {
25
+ flows: "*",
26
+ credentials: "*"
27
+ } : void 0
28
+ };
29
+ }
30
+ /**
31
+ * Simple in-memory sliding-window rate limiter.
32
+ *
33
+ * Keyed by IP address (or a fallback identifier). Tracks request timestamps
34
+ * per window and rejects requests that exceed the limit with HTTP 429.
35
+ *
36
+ * Only applied to authentication-sensitive endpoints (sign-in, sign-up,
37
+ * password reset) to prevent brute-force attacks. Session reads (GET) are
38
+ * not rate-limited.
39
+ */
40
+ var RateLimiter = class {
41
+ windows = /* @__PURE__ */ new Map();
42
+ maxRequests;
43
+ windowMs;
44
+ constructor(maxRequests = 10, windowMs = 6e4) {
45
+ this.maxRequests = maxRequests;
46
+ this.windowMs = windowMs;
47
+ }
48
+ /**
49
+ * Returns `true` if the request should be rejected (over limit).
50
+ */
51
+ isRateLimited(key) {
52
+ const now = Date.now();
53
+ const windowStart = now - this.windowMs;
54
+ let timestamps = this.windows.get(key);
55
+ if (!timestamps) {
56
+ timestamps = [];
57
+ this.windows.set(key, timestamps);
58
+ }
59
+ const valid = timestamps.filter((t) => t > windowStart);
60
+ this.windows.set(key, valid);
61
+ if (valid.length >= this.maxRequests) {
62
+ const retryAfterMs = (valid[0] ?? now) + this.windowMs - now;
63
+ return {
64
+ limited: true,
65
+ retryAfterMs: Math.max(retryAfterMs, 1e3)
66
+ };
67
+ }
68
+ valid.push(now);
69
+ return { limited: false };
70
+ }
71
+ /** Periodic cleanup of stale keys to prevent memory leaks. */
72
+ cleanup() {
73
+ const now = Date.now();
74
+ for (const [key, timestamps] of this.windows) {
75
+ const valid = timestamps.filter((t) => t > now - this.windowMs);
76
+ if (valid.length === 0) this.windows.delete(key);
77
+ else this.windows.set(key, valid);
78
+ }
79
+ }
80
+ };
81
+ /** Auth-sensitive path segments that should be rate-limited. */
82
+ const RATE_LIMITED_AUTH_PATHS = [
83
+ "/sign-in/",
84
+ "/sign-up/",
85
+ "/forgot-password",
86
+ "/reset-password"
87
+ ];
88
+ /**
89
+ * Convert a Node.js-style `IncomingHttpHeaders` record or a `Headers` instance
90
+ * to a standard `Headers` object for passing to better-auth.
91
+ */
92
+ function toHeaders(raw) {
93
+ if (raw instanceof Headers) return raw;
94
+ const headers = new Headers();
95
+ for (const [key, value] of Object.entries(raw)) if (value !== void 0) headers.set(key, value);
96
+ return headers;
97
+ }
98
+ /**
99
+ * Resolve the session from a better-auth instance using request headers.
100
+ */
101
+ async function resolveSession(auth, headers) {
102
+ if (!auth) return null;
103
+ const h = toHeaders(headers);
104
+ try {
105
+ console.log("[auth-debug] resolveSession: cookie header =", h.get("cookie")?.slice(0, 80));
106
+ const result = await auth.api.getSession({ headers: h });
107
+ console.log("[auth-debug] resolveSession: result keys =", result ? Object.keys(result) : "null");
108
+ if (result?.session && result?.user) return {
109
+ session: result.session,
110
+ user: result.user
111
+ };
112
+ return null;
113
+ } catch (err) {
114
+ console.error("[auth-debug] resolveSession threw:", err?.message ?? err);
115
+ return null;
116
+ }
117
+ }
118
+ async function callBetterAuthHandler(auth, request, path, init) {
119
+ if (!auth) return null;
120
+ const basePath = auth.options?.basePath ?? "/api/auth";
121
+ const targetUrl = new URL(`${basePath}${path}`, request.url);
122
+ for (const [key, value] of Object.entries(init?.query ?? {})) if (value !== void 0) targetUrl.searchParams.set(key, value);
123
+ const headers = new Headers(request.headers);
124
+ const hasBody = init?.body !== void 0;
125
+ if (hasBody && !headers.has("content-type")) headers.set("content-type", "application/json");
126
+ const authRequest = new Request(targetUrl.toString(), {
127
+ method: init?.method ?? "GET",
128
+ headers,
129
+ body: hasBody ? JSON.stringify(init?.body) : void 0
130
+ });
131
+ const response = await auth.handler(authRequest);
132
+ const text = await response.text();
133
+ if (!text) return {
134
+ status: response.status,
135
+ body: null
136
+ };
137
+ try {
138
+ return {
139
+ status: response.status,
140
+ body: JSON.parse(text)
141
+ };
142
+ } catch {
143
+ return {
144
+ status: response.status,
145
+ body: text
146
+ };
147
+ }
148
+ }
149
+ async function getAuthContext(auth) {
150
+ if (!auth) return null;
151
+ try {
152
+ return await auth.$context ?? null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ function isBetterAuthUser(value) {
158
+ return !!value && typeof value === "object" && "id" in value && typeof value.id === "string";
159
+ }
160
+ function unwrapFoundUser(result) {
161
+ if (!result) return null;
162
+ if (typeof result === "object" && "user" in result) {
163
+ const nestedUser = result.user;
164
+ if (isBetterAuthUser(nestedUser)) return nestedUser;
165
+ return null;
166
+ }
167
+ if (isBetterAuthUser(result)) return result;
168
+ return null;
169
+ }
170
+ function toAuthApiErrorResponse(fallbackError, error) {
171
+ if (error instanceof Response) return {
172
+ status: error.status || 500,
173
+ body: {
174
+ error: fallbackError,
175
+ message: error.statusText || fallbackError
176
+ }
177
+ };
178
+ const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status ?? 500 : error && typeof error === "object" && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode ?? 500 : 500;
179
+ const message = error && typeof error === "object" && "message" in error && typeof error.message === "string" ? error.message || fallbackError : fallbackError;
180
+ const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
181
+ return {
182
+ status,
183
+ body: {
184
+ error: fallbackError,
185
+ message,
186
+ ...code ? { code } : {}
187
+ }
188
+ };
189
+ }
190
+ function sanitizeForLogging(value) {
191
+ if (Array.isArray(value)) return value.map((item) => sanitizeForLogging(item));
192
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => {
193
+ if (/password|token|secret/i.test(key)) return [key, "[REDACTED]"];
194
+ return [key, sanitizeForLogging(nestedValue)];
195
+ }));
196
+ return value;
197
+ }
198
+ function getErrorLogDetails(error) {
199
+ if (error instanceof Response) return {
200
+ type: "Response",
201
+ status: error.status,
202
+ statusText: error.statusText
203
+ };
204
+ if (error instanceof Error) return {
205
+ name: error.name,
206
+ message: error.message,
207
+ stack: error.stack,
208
+ ...error && typeof error === "object" && "cause" in error ? { cause: sanitizeForLogging(error.cause) } : {},
209
+ ...error && typeof error === "object" && "code" in error ? { code: error.code } : {},
210
+ ...error && typeof error === "object" && "status" in error ? { status: error.status } : {},
211
+ ...error && typeof error === "object" && "statusCode" in error ? { statusCode: error.statusCode } : {}
212
+ };
213
+ if (error && typeof error === "object") return sanitizeForLogging(error);
214
+ return { value: error };
215
+ }
216
+ /**
217
+ * Abstract schema for better-auth's database tables.
218
+ *
219
+ * These definitions allow the Invect CLI (`npx invect generate`) to include
220
+ * the better-auth tables when generating Drizzle/Prisma schema files.
221
+ *
222
+ * The shapes match better-auth's default table structure. If your better-auth
223
+ * config adds extra fields (e.g., via plugins like `twoFactor`, `organization`),
224
+ * you can extend these in your own config.
225
+ */
226
+ const BETTER_AUTH_SCHEMA = {
227
+ user: {
228
+ tableName: "user",
229
+ order: 1,
230
+ fields: {
231
+ id: {
232
+ type: "string",
233
+ primaryKey: true
234
+ },
235
+ name: {
236
+ type: "string",
237
+ required: true
238
+ },
239
+ email: {
240
+ type: "string",
241
+ required: true,
242
+ unique: true
243
+ },
244
+ emailVerified: {
245
+ type: "boolean",
246
+ required: true,
247
+ defaultValue: false
248
+ },
249
+ image: {
250
+ type: "string",
251
+ required: false
252
+ },
253
+ role: {
254
+ type: "string",
255
+ required: false,
256
+ defaultValue: require_roles.AUTH_DEFAULT_ROLE
257
+ },
258
+ banned: {
259
+ type: "boolean",
260
+ required: false,
261
+ defaultValue: false
262
+ },
263
+ banReason: {
264
+ type: "string",
265
+ required: false
266
+ },
267
+ banExpires: {
268
+ type: "date",
269
+ required: false
270
+ },
271
+ createdAt: {
272
+ type: "date",
273
+ required: true,
274
+ defaultValue: "now()"
275
+ },
276
+ updatedAt: {
277
+ type: "date",
278
+ required: true,
279
+ defaultValue: "now()"
280
+ }
281
+ }
282
+ },
283
+ session: {
284
+ tableName: "session",
285
+ order: 2,
286
+ fields: {
287
+ id: {
288
+ type: "string",
289
+ primaryKey: true
290
+ },
291
+ expiresAt: {
292
+ type: "date",
293
+ required: true
294
+ },
295
+ token: {
296
+ type: "string",
297
+ required: true,
298
+ unique: true
299
+ },
300
+ createdAt: {
301
+ type: "date",
302
+ required: true,
303
+ defaultValue: "now()"
304
+ },
305
+ updatedAt: {
306
+ type: "date",
307
+ required: true,
308
+ defaultValue: "now()"
309
+ },
310
+ ipAddress: {
311
+ type: "string",
312
+ required: false
313
+ },
314
+ userAgent: {
315
+ type: "string",
316
+ required: false
317
+ },
318
+ impersonatedBy: {
319
+ type: "string",
320
+ required: false
321
+ },
322
+ userId: {
323
+ type: "string",
324
+ required: true,
325
+ references: {
326
+ table: "user",
327
+ field: "id",
328
+ onDelete: "cascade"
329
+ }
330
+ }
331
+ }
332
+ },
333
+ account: {
334
+ tableName: "account",
335
+ order: 2,
336
+ fields: {
337
+ id: {
338
+ type: "string",
339
+ primaryKey: true
340
+ },
341
+ accountId: {
342
+ type: "string",
343
+ required: true
344
+ },
345
+ providerId: {
346
+ type: "string",
347
+ required: true
348
+ },
349
+ userId: {
350
+ type: "string",
351
+ required: true,
352
+ references: {
353
+ table: "user",
354
+ field: "id",
355
+ onDelete: "cascade"
356
+ }
357
+ },
358
+ accessToken: {
359
+ type: "string",
360
+ required: false
361
+ },
362
+ refreshToken: {
363
+ type: "string",
364
+ required: false
365
+ },
366
+ idToken: {
367
+ type: "string",
368
+ required: false
369
+ },
370
+ accessTokenExpiresAt: {
371
+ type: "date",
372
+ required: false
373
+ },
374
+ refreshTokenExpiresAt: {
375
+ type: "date",
376
+ required: false
377
+ },
378
+ scope: {
379
+ type: "string",
380
+ required: false
381
+ },
382
+ password: {
383
+ type: "string",
384
+ required: false
385
+ },
386
+ createdAt: {
387
+ type: "date",
388
+ required: true,
389
+ defaultValue: "now()"
390
+ },
391
+ updatedAt: {
392
+ type: "date",
393
+ required: true,
394
+ defaultValue: "now()"
395
+ }
396
+ }
397
+ },
398
+ verification: {
399
+ tableName: "verification",
400
+ order: 2,
401
+ fields: {
402
+ id: {
403
+ type: "string",
404
+ primaryKey: true
405
+ },
406
+ identifier: {
407
+ type: "string",
408
+ required: true
409
+ },
410
+ value: {
411
+ type: "string",
412
+ required: true
413
+ },
414
+ expiresAt: {
415
+ type: "date",
416
+ required: true
417
+ },
418
+ createdAt: {
419
+ type: "date",
420
+ required: false
421
+ },
422
+ updatedAt: {
423
+ type: "date",
424
+ required: false
425
+ }
426
+ }
427
+ }
428
+ };
429
+ /**
430
+ * Create a better-auth instance internally using Invect's database config.
431
+ *
432
+ * Dynamically imports `better-auth` (a required peer dependency) and creates
433
+ * a fully-configured instance with email/password auth, the admin plugin,
434
+ * and session caching.
435
+ *
436
+ * Database resolution order:
437
+ * 1. Explicit `options.database` (any value `betterAuth({ database })` accepts)
438
+ * 2. Auto-created client from Invect's `baseDatabaseConfig.connectionString`
439
+ */
440
+ async function createInternalBetterAuth(invectConfig, options, logger) {
441
+ let betterAuthFn;
442
+ let adminPlugin;
443
+ try {
444
+ betterAuthFn = (await import("better-auth")).betterAuth;
445
+ } catch {
446
+ throw new Error("Could not import \"better-auth\". It is a required peer dependency of @invect/user-auth. Install it with: npm install better-auth");
447
+ }
448
+ try {
449
+ adminPlugin = (await import("better-auth/plugins")).admin;
450
+ } catch {
451
+ throw new Error("Could not import \"better-auth/plugins\". Ensure better-auth is properly installed.");
452
+ }
453
+ let database = options.database;
454
+ if (!database) {
455
+ const dbConfig = invectConfig.baseDatabaseConfig;
456
+ if (!dbConfig?.connectionString) throw new Error("Cannot create internal better-auth instance: no database configuration found. Either provide `auth` (a better-auth instance), `database`, or ensure Invect baseDatabaseConfig has a connectionString.");
457
+ const connStr = dbConfig.connectionString;
458
+ const dbType = (dbConfig.type ?? "sqlite").toLowerCase();
459
+ if (dbType === "sqlite") database = await createSQLiteClient(connStr, logger);
460
+ else if (dbType === "pg" || dbType === "postgresql") database = await createPostgresPool(connStr);
461
+ else if (dbType === "mysql") database = await createMySQLPool(connStr);
462
+ else throw new Error(`Unsupported database type for internal better-auth: "${dbType}". Supported: sqlite, pg, mysql. Alternatively, provide your own better-auth instance via \`auth\`.`);
463
+ }
464
+ const baseURL = options.baseURL ?? process.env.BETTER_AUTH_URL ?? `http://localhost:${process.env.PORT ?? "3000"}`;
465
+ const trustedOrigins = options.trustedOrigins ?? ((request) => {
466
+ const trusted = new Set([
467
+ baseURL,
468
+ "http://localhost:5173",
469
+ "http://localhost:5174"
470
+ ]);
471
+ try {
472
+ if (request) trusted.add(new URL(request.url).origin);
473
+ } catch {}
474
+ return Array.from(trusted);
475
+ });
476
+ const passthrough = options.betterAuthOptions ?? {};
477
+ const emailAndPassword = {
478
+ enabled: true,
479
+ ...passthrough.emailAndPassword
480
+ };
481
+ const session = {
482
+ cookieCache: {
483
+ enabled: true,
484
+ maxAge: 300
485
+ },
486
+ ...passthrough.session,
487
+ ...passthrough.session?.cookieCache ? { cookieCache: {
488
+ enabled: true,
489
+ maxAge: 300,
490
+ ...passthrough.session.cookieCache
491
+ } } : {}
492
+ };
493
+ logger.info?.("Creating internal better-auth instance");
494
+ return betterAuthFn({
495
+ baseURL,
496
+ database,
497
+ emailAndPassword,
498
+ plugins: [adminPlugin({
499
+ defaultRole: require_roles.AUTH_DEFAULT_ROLE,
500
+ adminRoles: [require_roles.AUTH_ADMIN_ROLE]
501
+ })],
502
+ session,
503
+ trustedOrigins,
504
+ ...passthrough.socialProviders ? { socialProviders: passthrough.socialProviders } : {},
505
+ ...passthrough.account ? { account: passthrough.account } : {},
506
+ ...passthrough.rateLimit ? { rateLimit: passthrough.rateLimit } : {},
507
+ ...passthrough.advanced ? { advanced: passthrough.advanced } : {},
508
+ ...passthrough.databaseHooks ? { databaseHooks: passthrough.databaseHooks } : {},
509
+ ...passthrough.hooks ? { hooks: passthrough.hooks } : {},
510
+ ...passthrough.disabledPaths ? { disabledPaths: passthrough.disabledPaths } : {},
511
+ ...passthrough.secret ? { secret: passthrough.secret } : {},
512
+ ...passthrough.secrets ? { secrets: passthrough.secrets } : {}
513
+ });
514
+ }
515
+ /** Create a SQLite client using better-sqlite3. */
516
+ async function createSQLiteClient(connectionString, logger) {
517
+ try {
518
+ const { default: Database } = await import("better-sqlite3");
519
+ const { Kysely, SqliteDialect, CamelCasePlugin } = await import("kysely");
520
+ logger.debug?.(`Using better-sqlite3 for internal better-auth database`);
521
+ let dbPath = connectionString.replace(/^file:/, "");
522
+ if (dbPath === "") dbPath = ":memory:";
523
+ return {
524
+ db: new Kysely({
525
+ dialect: new SqliteDialect({ database: new Database(dbPath) }),
526
+ plugins: [new CamelCasePlugin()]
527
+ }),
528
+ type: "sqlite"
529
+ };
530
+ } catch (err) {
531
+ if (err instanceof Error && err.message.includes("better-sqlite3")) throw new Error("Cannot create SQLite database for internal better-auth: install better-sqlite3 (npm install better-sqlite3). Alternatively, provide your own better-auth instance via the `auth` option.");
532
+ throw err;
533
+ }
534
+ }
535
+ /** Create a PostgreSQL pool from a connection string. */
536
+ async function createPostgresPool(connectionString) {
537
+ try {
538
+ const { Pool } = await import("pg");
539
+ return new Pool({ connectionString });
540
+ } catch {
541
+ throw new Error("Cannot create PostgreSQL pool for internal better-auth: install the \"pg\" package. Alternatively, provide your own better-auth instance via the `auth` option.");
542
+ }
543
+ }
544
+ /** Create a MySQL pool from a connection string. */
545
+ async function createMySQLPool(connectionString) {
546
+ try {
547
+ return (await import("mysql2/promise")).createPool(connectionString);
548
+ } catch {
549
+ throw new Error("Cannot create MySQL pool for internal better-auth: install the \"mysql2\" package. Alternatively, provide your own better-auth instance via the `auth` option.");
550
+ }
551
+ }
552
+ /**
553
+ * Create an Invect plugin that wraps a better-auth instance.
554
+ *
555
+ * This plugin:
556
+ *
557
+ * 1. **Proxies better-auth routes** — All of better-auth's HTTP endpoints
558
+ * (sign-in, sign-up, sign-out, OAuth callbacks, session, etc.) are mounted
559
+ * under the plugin endpoint space at `/plugins/auth/api/auth/*` (configurable).
560
+ *
561
+ * 2. **Resolves sessions → identities** — On every Invect API request, the
562
+ * `onRequest` hook reads the session cookie / bearer token via
563
+ * `auth.api.getSession()` and populates `InvectIdentity`.
564
+ *
565
+ * 3. **Handles authorization** — The `onAuthorize` hook lets better-auth's
566
+ * session decide whether a request is allowed.
567
+ *
568
+ * @example
569
+ * ```ts
570
+ * // Simple: let the plugin manage better-auth internally
571
+ * import { betterAuthPlugin } from '@invect/user-auth';
572
+ *
573
+ * app.use('/invect', createInvectRouter({
574
+ * databaseUrl: 'file:./dev.db',
575
+ * plugins: [betterAuthPlugin({
576
+ * globalAdmins: [{ email: 'admin@co.com', pw: 'secret' }],
577
+ * })],
578
+ * }));
579
+ * ```
580
+ *
581
+ * @example
582
+ * ```ts
583
+ * // Advanced: provide your own better-auth instance
584
+ * import { betterAuth } from 'better-auth';
585
+ * import { betterAuthPlugin } from '@invect/user-auth';
586
+ *
587
+ * const auth = betterAuth({
588
+ * database: { ... },
589
+ * emailAndPassword: { enabled: true },
590
+ * // ... your better-auth config
591
+ * });
592
+ *
593
+ * app.use('/invect', createInvectRouter({
594
+ * databaseUrl: 'file:./dev.db',
595
+ * plugins: [betterAuthPlugin({ auth })],
596
+ * }));
597
+ * ```
598
+ */
599
+ function betterAuthPlugin(options) {
600
+ const { prefix = DEFAULT_PREFIX, mapUser: customMapUser, mapRole = defaultMapRole, publicPaths = [], onSessionError = "throw", globalAdmins = [] } = options;
601
+ let auth = options.auth ?? null;
602
+ let endpointLogger = console;
603
+ let betterAuthBasePath = "/api/auth";
604
+ /**
605
+ * Resolve an identity from a request's headers.
606
+ */
607
+ async function getIdentityFromHeaders(headers) {
608
+ if (!auth) return null;
609
+ const result = await resolveSession(auth, headers);
610
+ if (!result) return null;
611
+ if (customMapUser) return customMapUser(result.user, result.session);
612
+ return defaultMapUser(result.user, result.session, mapRole);
613
+ }
614
+ async function getIdentityFromRequest(request) {
615
+ if (!auth) return null;
616
+ const body = (await callBetterAuthHandler(auth, request, "/get-session"))?.body;
617
+ if (!body?.session || !body?.user) return null;
618
+ if (customMapUser) return customMapUser(body.user, body.session);
619
+ return defaultMapUser(body.user, body.session, mapRole);
620
+ }
621
+ async function resolveEndpointIdentity(ctx) {
622
+ if (ctx.identity) return ctx.identity;
623
+ return getIdentityFromRequest(ctx.request);
624
+ }
625
+ const authRateLimiter = new RateLimiter(10, 6e4);
626
+ setInterval(() => authRateLimiter.cleanup(), 5 * 6e4).unref?.();
627
+ const endpoints = [
628
+ "GET",
629
+ "POST",
630
+ "PUT",
631
+ "PATCH",
632
+ "DELETE"
633
+ ].map((method) => ({
634
+ method,
635
+ path: `/${prefix}/*`,
636
+ isPublic: true,
637
+ handler: async (ctx) => {
638
+ const incomingUrl = new URL(ctx.request.url);
639
+ endpointLogger.debug?.(`[auth-proxy] ${method} ${incomingUrl.pathname}`);
640
+ const pluginPrefixPattern = `/plugins/${prefix}`;
641
+ let authPath = incomingUrl.pathname;
642
+ const prefixIdx = authPath.indexOf(pluginPrefixPattern);
643
+ if (prefixIdx !== -1) authPath = authPath.slice(prefixIdx + pluginPrefixPattern.length);
644
+ if (!authPath.startsWith("/")) authPath = "/" + authPath;
645
+ if (method === "POST" && RATE_LIMITED_AUTH_PATHS.some((p) => authPath.includes(p))) {
646
+ const clientIp = ctx.headers["x-forwarded-for"]?.split(",")[0]?.trim() || ctx.headers["x-real-ip"] || "unknown";
647
+ const { limited, retryAfterMs } = authRateLimiter.isRateLimited(clientIp);
648
+ if (limited) return new Response(JSON.stringify({
649
+ error: "Too Many Requests",
650
+ message: "Too many authentication attempts. Please try again later."
651
+ }), {
652
+ status: 429,
653
+ headers: {
654
+ "content-type": "application/json",
655
+ "retry-after": String(Math.ceil((retryAfterMs ?? 6e4) / 1e3))
656
+ }
657
+ });
658
+ }
659
+ const authUrl = new URL(`${incomingUrl.origin}${authPath}${incomingUrl.search}`);
660
+ endpointLogger.debug?.(`[auth-proxy] Forwarding to better-auth: ${method} ${authUrl.pathname}`);
661
+ const authRequest = new Request(authUrl.toString(), {
662
+ method: ctx.request.method,
663
+ headers: ctx.request.headers,
664
+ body: method !== "GET" && method !== "DELETE" ? ctx.request.body : void 0,
665
+ duplex: method !== "GET" && method !== "DELETE" ? "half" : void 0
666
+ });
667
+ const response = await auth.handler(authRequest);
668
+ endpointLogger.debug?.(`[auth-proxy] Response: ${response.status} ${response.statusText}`, {
669
+ setCookie: response.headers.get("set-cookie") ? "present" : "absent",
670
+ contentType: response.headers.get("content-type")
671
+ });
672
+ return response;
673
+ }
674
+ }));
675
+ return {
676
+ id: "better-auth",
677
+ name: "Better Auth",
678
+ schema: BETTER_AUTH_SCHEMA,
679
+ requiredTables: [
680
+ "user",
681
+ "session",
682
+ "account",
683
+ "verification"
684
+ ],
685
+ setupInstructions: "Run `npx invect generate` to add the better-auth tables to your schema, then `npx drizzle-kit push` (or `npx invect migrate`) to apply.",
686
+ endpoints: [
687
+ {
688
+ method: "GET",
689
+ path: `/${prefix}/me`,
690
+ isPublic: false,
691
+ handler: async (ctx) => {
692
+ const identity = await resolveEndpointIdentity(ctx);
693
+ const permissions = ctx.core.getPermissions(identity);
694
+ const resolvedRole = identity ? ctx.core.getResolvedRole(identity) : null;
695
+ return {
696
+ status: 200,
697
+ body: {
698
+ identity: identity ? {
699
+ id: identity.id,
700
+ name: identity.name,
701
+ role: identity.role,
702
+ resolvedRole
703
+ } : null,
704
+ permissions,
705
+ isAuthenticated: !!identity
706
+ }
707
+ };
708
+ }
709
+ },
710
+ {
711
+ method: "GET",
712
+ path: `/${prefix}/roles`,
713
+ isPublic: false,
714
+ handler: async (ctx) => {
715
+ const roles = ctx.core.getAvailableRoles();
716
+ const missingRoles = require_roles.AUTH_VISIBLE_ROLES.filter((role) => !roles.some((entry) => entry.role === role)).map((role) => ({
717
+ role,
718
+ permissions: []
719
+ }));
720
+ return {
721
+ status: 200,
722
+ body: { roles: [...roles, ...missingRoles] }
723
+ };
724
+ }
725
+ },
726
+ {
727
+ method: "GET",
728
+ path: `/${prefix}/users`,
729
+ isPublic: false,
730
+ handler: async (ctx) => {
731
+ const identity = await resolveEndpointIdentity(ctx);
732
+ if (!identity || identity.role !== "admin") return {
733
+ status: 403,
734
+ body: {
735
+ error: "Forbidden",
736
+ message: "Admin access required"
737
+ }
738
+ };
739
+ try {
740
+ const api = auth.api;
741
+ const headers = toHeaders(ctx.headers);
742
+ if (typeof api.listUsers === "function") {
743
+ const listUsers = api.listUsers;
744
+ const result = await listUsers({
745
+ headers,
746
+ query: {
747
+ limit: ctx.query.limit ?? "100",
748
+ offset: ctx.query.offset ?? "0"
749
+ }
750
+ });
751
+ return {
752
+ status: 200,
753
+ body: { users: Array.isArray(result) ? result : result?.users ?? [] }
754
+ };
755
+ }
756
+ const fallbackResult = await callBetterAuthHandler(auth, ctx.request, "/admin/list-users", {
757
+ method: "GET",
758
+ query: {
759
+ limit: ctx.query.limit ?? "100",
760
+ offset: ctx.query.offset ?? "0"
761
+ }
762
+ });
763
+ if (fallbackResult && fallbackResult.status >= 200 && fallbackResult.status < 300) return {
764
+ status: 200,
765
+ body: fallbackResult.body
766
+ };
767
+ return {
768
+ status: 200,
769
+ body: {
770
+ users: [],
771
+ message: "User listing requires the better-auth admin plugin. Add `admin()` to your better-auth plugins config."
772
+ }
773
+ };
774
+ } catch (err) {
775
+ endpointLogger.error("Failed to list users", {
776
+ identity: sanitizeForLogging(identity),
777
+ query: sanitizeForLogging(ctx.query),
778
+ error: getErrorLogDetails(err)
779
+ });
780
+ return toAuthApiErrorResponse("Failed to list users", err);
781
+ }
782
+ }
783
+ },
784
+ {
785
+ method: "POST",
786
+ path: `/${prefix}/users`,
787
+ isPublic: false,
788
+ handler: async (ctx) => {
789
+ const identity = await resolveEndpointIdentity(ctx);
790
+ if (!identity || identity.role !== "admin") return {
791
+ status: 403,
792
+ body: {
793
+ error: "Forbidden",
794
+ message: "Admin access required"
795
+ }
796
+ };
797
+ const { email, password, name, role } = ctx.body;
798
+ if (!email || !password) return {
799
+ status: 400,
800
+ body: { error: "email and password are required" }
801
+ };
802
+ if (role !== void 0 && !require_roles.isAuthAssignableRole(role)) return {
803
+ status: 400,
804
+ body: { error: "role must be one of: " + require_roles.AUTH_ASSIGNABLE_ROLES.join(", ") }
805
+ };
806
+ try {
807
+ const api = auth.api;
808
+ const headers = toHeaders(ctx.headers);
809
+ let result = null;
810
+ if (typeof api.createUser === "function") {
811
+ const createUser = api.createUser;
812
+ result = await createUser({
813
+ headers,
814
+ body: {
815
+ email,
816
+ password,
817
+ name: name ?? email.split("@")[0],
818
+ role: role ?? "default"
819
+ }
820
+ });
821
+ } else if (typeof api.signUpEmail === "function") {
822
+ const signUpEmail = api.signUpEmail;
823
+ result = await signUpEmail({
824
+ headers,
825
+ body: {
826
+ email,
827
+ password,
828
+ name: name ?? email.split("@")[0],
829
+ role: role ?? "default"
830
+ }
831
+ });
832
+ } else {
833
+ const fallbackResult = await callBetterAuthHandler(auth, ctx.request, "/admin/create-user", {
834
+ method: "POST",
835
+ body: {
836
+ email,
837
+ password,
838
+ name: name ?? email.split("@")[0],
839
+ role: role ?? "default"
840
+ }
841
+ });
842
+ if (fallbackResult && fallbackResult.status >= 200 && fallbackResult.status < 300) result = fallbackResult.body;
843
+ }
844
+ if (!result && typeof api.createUser !== "function" && typeof api.signUpEmail !== "function") return {
845
+ status: 500,
846
+ body: { error: "Auth API does not support user creation" }
847
+ };
848
+ if (!result?.user) return {
849
+ status: 400,
850
+ body: {
851
+ error: "Failed to create user",
852
+ message: "User may already exist"
853
+ }
854
+ };
855
+ return {
856
+ status: 201,
857
+ body: { user: {
858
+ id: result.user.id,
859
+ email: result.user.email,
860
+ name: result.user.name,
861
+ role: result.user.role
862
+ } }
863
+ };
864
+ } catch (err) {
865
+ endpointLogger.error("Failed to create user", {
866
+ identity: sanitizeForLogging(identity),
867
+ body: sanitizeForLogging({
868
+ email,
869
+ name,
870
+ role
871
+ }),
872
+ error: getErrorLogDetails(err)
873
+ });
874
+ return toAuthApiErrorResponse("Failed to create user", err);
875
+ }
876
+ }
877
+ },
878
+ {
879
+ method: "PATCH",
880
+ path: `/${prefix}/users/:userId/role`,
881
+ isPublic: false,
882
+ handler: async (ctx) => {
883
+ const identity = await resolveEndpointIdentity(ctx);
884
+ if (!identity || identity.role !== "admin") return {
885
+ status: 403,
886
+ body: {
887
+ error: "Forbidden",
888
+ message: "Admin access required"
889
+ }
890
+ };
891
+ const { userId } = ctx.params;
892
+ const { role } = ctx.body;
893
+ if (!require_roles.isAuthAssignableRole(role)) return {
894
+ status: 400,
895
+ body: { error: "role must be one of: " + require_roles.AUTH_ASSIGNABLE_ROLES.join(", ") }
896
+ };
897
+ try {
898
+ const api = auth.api;
899
+ const headers = toHeaders(ctx.headers);
900
+ if (typeof api.setRole === "function") {
901
+ const setRole = api.setRole;
902
+ await setRole({
903
+ headers,
904
+ body: {
905
+ userId,
906
+ role
907
+ }
908
+ });
909
+ return {
910
+ status: 200,
911
+ body: {
912
+ success: true,
913
+ userId,
914
+ role
915
+ }
916
+ };
917
+ }
918
+ const fallbackResult = await callBetterAuthHandler(auth, ctx.request, "/admin/set-role", {
919
+ method: "POST",
920
+ body: {
921
+ userId,
922
+ role
923
+ }
924
+ });
925
+ if (fallbackResult && fallbackResult.status >= 200 && fallbackResult.status < 300) return {
926
+ status: 200,
927
+ body: {
928
+ success: true,
929
+ userId,
930
+ role
931
+ }
932
+ };
933
+ if (typeof api.updateUser === "function") {
934
+ const updateUser = api.updateUser;
935
+ await updateUser({
936
+ headers,
937
+ body: { role },
938
+ params: { id: userId }
939
+ });
940
+ return {
941
+ status: 200,
942
+ body: {
943
+ success: true,
944
+ userId,
945
+ role
946
+ }
947
+ };
948
+ }
949
+ return {
950
+ status: 501,
951
+ body: { error: "Role update requires the better-auth admin plugin. Add `admin()` to your better-auth plugins config." }
952
+ };
953
+ } catch (err) {
954
+ endpointLogger.error("Failed to update role", {
955
+ identity: sanitizeForLogging(identity),
956
+ params: sanitizeForLogging(ctx.params),
957
+ body: sanitizeForLogging({ role }),
958
+ error: getErrorLogDetails(err)
959
+ });
960
+ return toAuthApiErrorResponse("Failed to update role", err);
961
+ }
962
+ }
963
+ },
964
+ {
965
+ method: "DELETE",
966
+ path: `/${prefix}/users/:userId`,
967
+ isPublic: false,
968
+ handler: async (ctx) => {
969
+ const identity = await resolveEndpointIdentity(ctx);
970
+ if (!identity || identity.role !== "admin") return {
971
+ status: 403,
972
+ body: {
973
+ error: "Forbidden",
974
+ message: "Admin access required"
975
+ }
976
+ };
977
+ const { userId } = ctx.params;
978
+ if (identity.id === userId) return {
979
+ status: 400,
980
+ body: { error: "Cannot delete your own account" }
981
+ };
982
+ try {
983
+ const api = auth.api;
984
+ const headers = toHeaders(ctx.headers);
985
+ if (typeof api.removeUser === "function") {
986
+ const removeUser = api.removeUser;
987
+ await removeUser({
988
+ headers,
989
+ body: { userId }
990
+ });
991
+ return {
992
+ status: 200,
993
+ body: {
994
+ success: true,
995
+ userId
996
+ }
997
+ };
998
+ }
999
+ const fallbackResult = await callBetterAuthHandler(auth, ctx.request, "/admin/remove-user", {
1000
+ method: "POST",
1001
+ body: { userId }
1002
+ });
1003
+ if (fallbackResult && fallbackResult.status >= 200 && fallbackResult.status < 300) return {
1004
+ status: 200,
1005
+ body: {
1006
+ success: true,
1007
+ userId
1008
+ }
1009
+ };
1010
+ if (typeof api.deleteUser === "function") {
1011
+ const deleteUser = api.deleteUser;
1012
+ await deleteUser({
1013
+ headers,
1014
+ body: { userId }
1015
+ });
1016
+ return {
1017
+ status: 200,
1018
+ body: {
1019
+ success: true,
1020
+ userId
1021
+ }
1022
+ };
1023
+ }
1024
+ return {
1025
+ status: 501,
1026
+ body: { error: "User deletion requires the better-auth admin plugin. Add `admin()` to your better-auth plugins config." }
1027
+ };
1028
+ } catch (err) {
1029
+ endpointLogger.error("Failed to delete user", {
1030
+ identity: sanitizeForLogging(identity),
1031
+ params: sanitizeForLogging(ctx.params),
1032
+ error: getErrorLogDetails(err)
1033
+ });
1034
+ return toAuthApiErrorResponse("Failed to delete user", err);
1035
+ }
1036
+ }
1037
+ },
1038
+ ...endpoints
1039
+ ],
1040
+ hooks: {
1041
+ onRequest: async (request, context) => {
1042
+ if (isBetterAuthRoute(context.path, prefix, betterAuthBasePath)) return;
1043
+ if (publicPaths.some((p) => context.path.startsWith(p))) return;
1044
+ const headersRecord = {};
1045
+ request.headers.forEach((value, key) => {
1046
+ headersRecord[key] = value;
1047
+ });
1048
+ endpointLogger.debug?.(`[auth-onRequest] ${context.method} ${context.path}`, {
1049
+ hasCookie: !!headersRecord["cookie"],
1050
+ hasAuth: !!headersRecord["authorization"]
1051
+ });
1052
+ const identity = await getIdentityFromHeaders(headersRecord);
1053
+ endpointLogger.debug?.(`[auth-onRequest] Identity resolved:`, {
1054
+ authenticated: !!identity,
1055
+ userId: identity?.id,
1056
+ role: identity?.role
1057
+ });
1058
+ context.identity = identity;
1059
+ if (!identity && onSessionError === "throw") return { response: new Response(JSON.stringify({
1060
+ error: "Unauthorized",
1061
+ message: "Valid session required. Sign in via better-auth."
1062
+ }), {
1063
+ status: 401,
1064
+ headers: { "content-type": "application/json" }
1065
+ }) };
1066
+ },
1067
+ onAuthorize: async (context) => {
1068
+ if (context.identity) return;
1069
+ return {
1070
+ allowed: false,
1071
+ reason: "No valid better-auth session"
1072
+ };
1073
+ }
1074
+ },
1075
+ init: async (pluginContext) => {
1076
+ endpointLogger = pluginContext.logger;
1077
+ if (!auth) auth = await createInternalBetterAuth(pluginContext.config, options, pluginContext.logger);
1078
+ betterAuthBasePath = auth.options?.basePath ?? "/api/auth";
1079
+ pluginContext.logger.info(`Better Auth plugin initialized (prefix: ${prefix}, basePath: ${betterAuthBasePath})`);
1080
+ if (globalAdmins.length === 0) {
1081
+ pluginContext.logger.debug("No global admins configured. Pass `globalAdmins` to betterAuthPlugin(...) to seed admin access.");
1082
+ return;
1083
+ }
1084
+ for (const configuredAdmin of globalAdmins) {
1085
+ const adminEmail = configuredAdmin.email?.trim();
1086
+ const adminPassword = configuredAdmin.pw;
1087
+ const adminName = configuredAdmin.name?.trim() || "Admin";
1088
+ if (!adminEmail || !adminPassword) {
1089
+ pluginContext.logger.debug("Skipping invalid global admin config: both email and pw are required.");
1090
+ continue;
1091
+ }
1092
+ try {
1093
+ const authContext = await getAuthContext(auth);
1094
+ const existingAdminUser = unwrapFoundUser(await authContext?.internalAdapter?.findUserByEmail(adminEmail));
1095
+ if (existingAdminUser) {
1096
+ if (existingAdminUser.role !== "admin") {
1097
+ await authContext?.internalAdapter?.updateUser(existingAdminUser.id, { role: "admin" });
1098
+ pluginContext.logger.info(`Admin user promoted: ${adminEmail}`);
1099
+ } else pluginContext.logger.debug(`Admin user already configured: ${adminEmail}`);
1100
+ continue;
1101
+ }
1102
+ const api = auth.api;
1103
+ let result = null;
1104
+ if (typeof api.createUser === "function") {
1105
+ const createUser = api.createUser;
1106
+ result = await createUser({
1107
+ headers: new Headers(),
1108
+ body: {
1109
+ email: adminEmail,
1110
+ password: adminPassword,
1111
+ name: adminName,
1112
+ role: "admin"
1113
+ }
1114
+ }).catch((err) => {
1115
+ pluginContext.logger.error?.(`createUser failed for ${adminEmail}: ${err instanceof Error ? err.message : String(err)}`);
1116
+ return null;
1117
+ });
1118
+ } else if (typeof api.signUpEmail === "function") {
1119
+ const signUpEmail = api.signUpEmail;
1120
+ result = await signUpEmail({ body: {
1121
+ email: adminEmail,
1122
+ password: adminPassword,
1123
+ name: adminName
1124
+ } }).catch((err) => {
1125
+ pluginContext.logger.error?.(`signUpEmail failed for ${adminEmail}: ${err instanceof Error ? err.message : String(err)}`);
1126
+ return null;
1127
+ });
1128
+ } else {
1129
+ pluginContext.logger.debug(`Could not create global admin ${adminEmail}: auth.api.createUser/signUpEmail are unavailable.`);
1130
+ continue;
1131
+ }
1132
+ if (result?.user) {
1133
+ const createdAuthContext = authContext ?? await getAuthContext(auth);
1134
+ const createdAdminUser = unwrapFoundUser(await createdAuthContext?.internalAdapter?.findUserByEmail(adminEmail)) ?? result.user;
1135
+ if (createdAdminUser?.id && createdAdminUser.role !== "admin") await createdAuthContext?.internalAdapter?.updateUser(createdAdminUser.id, { role: "admin" });
1136
+ pluginContext.logger.info(`Admin user created: ${adminEmail}`);
1137
+ } else pluginContext.logger.debug(`Admin user already exists or could not be created: ${adminEmail}`);
1138
+ } catch (seedErr) {
1139
+ pluginContext.logger.debug(`Could not seed admin user (tables may not exist yet): ${adminEmail} — ${seedErr instanceof Error ? seedErr.message : String(seedErr)}`);
1140
+ }
1141
+ }
1142
+ },
1143
+ $ERROR_CODES: {
1144
+ "auth:session_expired": {
1145
+ message: "Session has expired. Please sign in again.",
1146
+ status: 401
1147
+ },
1148
+ "auth:session_not_found": {
1149
+ message: "No valid session found.",
1150
+ status: 401
1151
+ }
1152
+ }
1153
+ };
1154
+ }
1155
+ /**
1156
+ * Check if a path is a better-auth proxy route (should skip session checks).
1157
+ * Only matches the actual better-auth API proxy routes, not custom plugin endpoints.
1158
+ */
1159
+ function isBetterAuthRoute(path, prefix, basePath) {
1160
+ return path.startsWith(`/plugins/${prefix}${basePath}`);
1161
+ }
1162
+ //#endregion
1163
+ exports.BETTER_AUTH_SCHEMA = BETTER_AUTH_SCHEMA;
1164
+ exports.betterAuthPlugin = betterAuthPlugin;
1165
+
1166
+ //# sourceMappingURL=index.cjs.map