@momentumcms/auth 0.0.1 → 0.1.2

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 (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/index.js +1176 -0
  3. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 0.1.2 (2026-02-16)
2
+
3
+ ### 🩹 Fixes
4
+
5
+ - **release:** centralize manifestRootsToUpdate to update both source and dist ([2b8f832](https://github.com/DonaldMurillo/momentum-cms/commit/2b8f832))
6
+ - **create-app:** fix Angular SSR, Analog builds, and CJS/ESM compatibility ([28d4d0a](https://github.com/DonaldMurillo/momentum-cms/commit/28d4d0a))
7
+
8
+ ### ❤️ Thank You
9
+
10
+ - Claude Opus 4.6
11
+ - Donald Murillo @DonaldMurillo
12
+
1
13
  ## 0.1.1 (2026-02-16)
2
14
 
3
15
  ### 🩹 Fixes
package/index.js ADDED
@@ -0,0 +1,1176 @@
1
+ // libs/auth/src/lib/auth.ts
2
+ import { betterAuth } from "better-auth";
3
+ import { twoFactor } from "better-auth/plugins";
4
+
5
+ // libs/auth/src/lib/email.ts
6
+ import * as nodemailer from "nodemailer";
7
+ function getEnvConfig() {
8
+ const config = {};
9
+ if (process.env["SMTP_HOST"]) {
10
+ config.host = process.env["SMTP_HOST"];
11
+ }
12
+ if (process.env["SMTP_PORT"]) {
13
+ config.port = parseInt(process.env["SMTP_PORT"], 10);
14
+ }
15
+ if (process.env["SMTP_FROM"]) {
16
+ config.from = process.env["SMTP_FROM"];
17
+ }
18
+ if (process.env["SMTP_SECURE"]) {
19
+ config.secure = process.env["SMTP_SECURE"] === "true";
20
+ }
21
+ if (process.env["SMTP_USER"] && process.env["SMTP_PASS"]) {
22
+ config.auth = {
23
+ user: process.env["SMTP_USER"],
24
+ pass: process.env["SMTP_PASS"]
25
+ };
26
+ }
27
+ return config;
28
+ }
29
+ function createEmailService(config) {
30
+ const envConfig = getEnvConfig();
31
+ const finalConfig = {
32
+ host: config?.host ?? envConfig.host ?? "localhost",
33
+ port: config?.port ?? envConfig.port ?? 1025,
34
+ from: config?.from ?? envConfig.from ?? "noreply@momentum.local",
35
+ secure: config?.secure ?? envConfig.secure ?? false,
36
+ auth: config?.auth ?? envConfig.auth
37
+ };
38
+ const transportOptions = {
39
+ host: finalConfig.host,
40
+ port: finalConfig.port,
41
+ secure: finalConfig.secure
42
+ };
43
+ if (finalConfig.auth) {
44
+ transportOptions.auth = finalConfig.auth;
45
+ }
46
+ const transporter = nodemailer.createTransport(transportOptions);
47
+ return {
48
+ async sendEmail(options) {
49
+ await transporter.sendMail({
50
+ from: finalConfig.from,
51
+ to: options.to,
52
+ subject: options.subject,
53
+ text: options.text,
54
+ html: options.html
55
+ });
56
+ }
57
+ };
58
+ }
59
+
60
+ // libs/auth/src/lib/email-templates.ts
61
+ function escapeHtml(unsafe) {
62
+ return unsafe.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
63
+ }
64
+ function wrapEmail(content, safeAppName) {
65
+ return `
66
+ <!DOCTYPE html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="UTF-8">
70
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
71
+ <title>${safeAppName}</title>
72
+ </head>
73
+ <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f5; line-height: 1.6;">
74
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f4f4f5;">
75
+ <tr>
76
+ <td style="padding: 40px 20px;">
77
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
78
+ <tr>
79
+ <td style="padding: 40px;">
80
+ ${content}
81
+ </td>
82
+ </tr>
83
+ </table>
84
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px; margin: 20px auto 0;">
85
+ <tr>
86
+ <td style="text-align: center; color: #71717a; font-size: 12px;">
87
+ <p style="margin: 0;">&copy; ${(/* @__PURE__ */ new Date()).getFullYear()} ${safeAppName}. All rights reserved.</p>
88
+ </td>
89
+ </tr>
90
+ </table>
91
+ </td>
92
+ </tr>
93
+ </table>
94
+ </body>
95
+ </html>
96
+ `.trim();
97
+ }
98
+ function getPasswordResetEmail(options) {
99
+ const { name, url, appName = "Momentum CMS", expiresIn = "1 hour" } = options;
100
+ const greeting = name ? `Hi ${name},` : "Hi,";
101
+ const subject = `Reset your password - ${appName}`;
102
+ const text2 = `
103
+ ${greeting}
104
+
105
+ We received a request to reset your password. Click the link below to choose a new password:
106
+
107
+ ${url}
108
+
109
+ This link will expire in ${expiresIn}.
110
+
111
+ If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
112
+
113
+ Thanks,
114
+ The ${appName} Team
115
+ `.trim();
116
+ const safeGreeting = name ? `Hi ${escapeHtml(name)},` : "Hi,";
117
+ const safeUrl = escapeHtml(url);
118
+ const safeAppName = escapeHtml(appName);
119
+ const safeExpiresIn = escapeHtml(expiresIn);
120
+ const html = wrapEmail(
121
+ `
122
+ <h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #18181b;">Reset your password</h1>
123
+ <p style="margin: 0 0 16px; color: #3f3f46;">${safeGreeting}</p>
124
+ <p style="margin: 0 0 24px; color: #3f3f46;">We received a request to reset your password. Click the button below to choose a new password:</p>
125
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0">
126
+ <tr>
127
+ <td style="padding: 0 0 24px;">
128
+ <a href="${safeUrl}" style="display: inline-block; padding: 12px 24px; background-color: #18181b; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500;">Reset Password</a>
129
+ </td>
130
+ </tr>
131
+ </table>
132
+ <p style="margin: 0 0 8px; color: #71717a; font-size: 14px;">This link will expire in ${safeExpiresIn}.</p>
133
+ <p style="margin: 0 0 24px; color: #71717a; font-size: 14px;">If you didn't request a password reset, you can safely ignore this email.</p>
134
+ <hr style="border: none; border-top: 1px solid #e4e4e7; margin: 24px 0;">
135
+ <p style="margin: 0; color: #71717a; font-size: 12px;">If the button doesn't work, copy and paste this URL into your browser:</p>
136
+ <p style="margin: 8px 0 0; color: #71717a; font-size: 12px; word-break: break-all;">${safeUrl}</p>
137
+ `,
138
+ safeAppName
139
+ );
140
+ return { subject, text: text2, html };
141
+ }
142
+ function getVerificationEmail(options) {
143
+ const { name, url, appName = "Momentum CMS", expiresIn = "24 hours" } = options;
144
+ const greeting = name ? `Hi ${name},` : "Hi,";
145
+ const subject = `Verify your email - ${appName}`;
146
+ const text2 = `
147
+ ${greeting}
148
+
149
+ Welcome to ${appName}! Please verify your email address by clicking the link below:
150
+
151
+ ${url}
152
+
153
+ This link will expire in ${expiresIn}.
154
+
155
+ If you didn't create an account, you can safely ignore this email.
156
+
157
+ Thanks,
158
+ The ${appName} Team
159
+ `.trim();
160
+ const safeGreeting = name ? `Hi ${escapeHtml(name)},` : "Hi,";
161
+ const safeUrl = escapeHtml(url);
162
+ const safeAppName = escapeHtml(appName);
163
+ const safeExpiresIn = escapeHtml(expiresIn);
164
+ const html = wrapEmail(
165
+ `
166
+ <h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #18181b;">Verify your email</h1>
167
+ <p style="margin: 0 0 16px; color: #3f3f46;">${safeGreeting}</p>
168
+ <p style="margin: 0 0 24px; color: #3f3f46;">Welcome to ${safeAppName}! Please verify your email address by clicking the button below:</p>
169
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0">
170
+ <tr>
171
+ <td style="padding: 0 0 24px;">
172
+ <a href="${safeUrl}" style="display: inline-block; padding: 12px 24px; background-color: #18181b; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500;">Verify Email</a>
173
+ </td>
174
+ </tr>
175
+ </table>
176
+ <p style="margin: 0 0 8px; color: #71717a; font-size: 14px;">This link will expire in ${safeExpiresIn}.</p>
177
+ <p style="margin: 0 0 24px; color: #71717a; font-size: 14px;">If you didn't create an account, you can safely ignore this email.</p>
178
+ <hr style="border: none; border-top: 1px solid #e4e4e7; margin: 24px 0;">
179
+ <p style="margin: 0; color: #71717a; font-size: 12px;">If the button doesn't work, copy and paste this URL into your browser:</p>
180
+ <p style="margin: 8px 0 0; color: #71717a; font-size: 12px; word-break: break-all;">${safeUrl}</p>
181
+ `,
182
+ safeAppName
183
+ );
184
+ return { subject, text: text2, html };
185
+ }
186
+
187
+ // libs/logger/src/lib/log-level.ts
188
+ var LOG_LEVEL_VALUES = {
189
+ debug: 0,
190
+ info: 1,
191
+ warn: 2,
192
+ error: 3,
193
+ fatal: 4,
194
+ silent: 5
195
+ };
196
+ function shouldLog(messageLevel, configuredLevel) {
197
+ return LOG_LEVEL_VALUES[messageLevel] >= LOG_LEVEL_VALUES[configuredLevel];
198
+ }
199
+
200
+ // libs/logger/src/lib/ansi-colors.ts
201
+ var ANSI = {
202
+ reset: "\x1B[0m",
203
+ bold: "\x1B[1m",
204
+ dim: "\x1B[2m",
205
+ // Foreground colors
206
+ red: "\x1B[31m",
207
+ green: "\x1B[32m",
208
+ yellow: "\x1B[33m",
209
+ blue: "\x1B[34m",
210
+ magenta: "\x1B[35m",
211
+ cyan: "\x1B[36m",
212
+ white: "\x1B[37m",
213
+ gray: "\x1B[90m",
214
+ // Background colors
215
+ bgRed: "\x1B[41m",
216
+ bgYellow: "\x1B[43m"
217
+ };
218
+ function colorize(text2, ...codes) {
219
+ if (codes.length === 0)
220
+ return text2;
221
+ return `${codes.join("")}${text2}${ANSI.reset}`;
222
+ }
223
+ function supportsColor() {
224
+ if (process.env["FORCE_COLOR"] === "1")
225
+ return true;
226
+ if (process.env["NO_COLOR"] !== void 0)
227
+ return false;
228
+ if (process.env["TERM"] === "dumb")
229
+ return false;
230
+ return process.stdout.isTTY === true;
231
+ }
232
+
233
+ // libs/logger/src/lib/formatters.ts
234
+ var LEVEL_COLORS = {
235
+ debug: [ANSI.dim, ANSI.gray],
236
+ info: [ANSI.cyan],
237
+ warn: [ANSI.yellow],
238
+ error: [ANSI.red],
239
+ fatal: [ANSI.bold, ANSI.white, ANSI.bgRed]
240
+ };
241
+ function padLevel(level) {
242
+ return level.toUpperCase().padEnd(5);
243
+ }
244
+ function formatTimestamp(date2) {
245
+ const y = date2.getFullYear();
246
+ const mo = String(date2.getMonth() + 1).padStart(2, "0");
247
+ const d = String(date2.getDate()).padStart(2, "0");
248
+ const h = String(date2.getHours()).padStart(2, "0");
249
+ const mi = String(date2.getMinutes()).padStart(2, "0");
250
+ const s = String(date2.getSeconds()).padStart(2, "0");
251
+ const ms = String(date2.getMilliseconds()).padStart(3, "0");
252
+ return `${y}-${mo}-${d} ${h}:${mi}:${s}.${ms}`;
253
+ }
254
+ function formatData(data) {
255
+ const entries = Object.entries(data);
256
+ if (entries.length === 0)
257
+ return "";
258
+ return " " + entries.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ");
259
+ }
260
+ function prettyFormatter(entry) {
261
+ const useColor = supportsColor();
262
+ const level = entry.level;
263
+ const ts = formatTimestamp(entry.timestamp);
264
+ const levelStr = padLevel(entry.level);
265
+ const ctx = `[${entry.context}]`;
266
+ const msg = entry.message;
267
+ const enrichmentStr = entry.enrichments ? formatData(entry.enrichments) : "";
268
+ const dataStr = entry.data ? formatData(entry.data) : "";
269
+ const extra = `${enrichmentStr}${dataStr}`;
270
+ if (useColor) {
271
+ const colors = LEVEL_COLORS[level];
272
+ const coloredLevel = colorize(levelStr, ...colors);
273
+ const coloredCtx = colorize(ctx, ANSI.magenta);
274
+ const coloredTs = colorize(ts, ANSI.gray);
275
+ return `${coloredTs} ${coloredLevel} ${coloredCtx} ${msg}${extra}
276
+ `;
277
+ }
278
+ return `${ts} ${levelStr} ${ctx} ${msg}${extra}
279
+ `;
280
+ }
281
+ function jsonFormatter(entry) {
282
+ const output = {
283
+ timestamp: entry.timestamp.toISOString(),
284
+ level: entry.level,
285
+ context: entry.context,
286
+ message: entry.message
287
+ };
288
+ if (entry.enrichments && Object.keys(entry.enrichments).length > 0) {
289
+ Object.assign(output, entry.enrichments);
290
+ }
291
+ if (entry.data && Object.keys(entry.data).length > 0) {
292
+ output["data"] = entry.data;
293
+ }
294
+ return JSON.stringify(output) + "\n";
295
+ }
296
+
297
+ // libs/logger/src/lib/logger-config.types.ts
298
+ function resolveLoggingConfig(config) {
299
+ return {
300
+ level: config?.level ?? "info",
301
+ format: config?.format ?? "pretty",
302
+ timestamps: config?.timestamps ?? true,
303
+ output: config?.output ?? ((msg) => {
304
+ process.stdout.write(msg);
305
+ }),
306
+ errorOutput: config?.errorOutput ?? ((msg) => {
307
+ process.stderr.write(msg);
308
+ })
309
+ };
310
+ }
311
+
312
+ // libs/logger/src/lib/logger.ts
313
+ var ERROR_LEVELS = /* @__PURE__ */ new Set(["warn", "error", "fatal"]);
314
+ var MomentumLogger = class _MomentumLogger {
315
+ static {
316
+ this.enrichers = [];
317
+ }
318
+ constructor(context, config) {
319
+ this.context = context;
320
+ this.config = isResolvedConfig(config) ? config : resolveLoggingConfig(config);
321
+ this.formatter = this.config.format === "json" ? jsonFormatter : prettyFormatter;
322
+ }
323
+ debug(message, data) {
324
+ this.log("debug", message, data);
325
+ }
326
+ info(message, data) {
327
+ this.log("info", message, data);
328
+ }
329
+ warn(message, data) {
330
+ this.log("warn", message, data);
331
+ }
332
+ error(message, data) {
333
+ this.log("error", message, data);
334
+ }
335
+ fatal(message, data) {
336
+ this.log("fatal", message, data);
337
+ }
338
+ /**
339
+ * Creates a child logger with a sub-context.
340
+ * e.g., `Momentum:DB` → `Momentum:DB:Migrate`
341
+ */
342
+ child(subContext) {
343
+ return new _MomentumLogger(`${this.context}:${subContext}`, this.config);
344
+ }
345
+ /**
346
+ * Registers a global enricher that adds extra fields to all log entries.
347
+ */
348
+ static registerEnricher(enricher) {
349
+ _MomentumLogger.enrichers.push(enricher);
350
+ }
351
+ /**
352
+ * Removes a previously registered enricher.
353
+ */
354
+ static removeEnricher(enricher) {
355
+ const index = _MomentumLogger.enrichers.indexOf(enricher);
356
+ if (index >= 0) {
357
+ _MomentumLogger.enrichers.splice(index, 1);
358
+ }
359
+ }
360
+ /**
361
+ * Clears all registered enrichers. Primarily for testing.
362
+ */
363
+ static clearEnrichers() {
364
+ _MomentumLogger.enrichers.length = 0;
365
+ }
366
+ log(level, message, data) {
367
+ if (!shouldLog(level, this.config.level))
368
+ return;
369
+ const enrichments = this.collectEnrichments();
370
+ const entry = {
371
+ timestamp: /* @__PURE__ */ new Date(),
372
+ level,
373
+ context: this.context,
374
+ message,
375
+ data,
376
+ enrichments: Object.keys(enrichments).length > 0 ? enrichments : void 0
377
+ };
378
+ const formatted = this.formatter(entry);
379
+ if (ERROR_LEVELS.has(level)) {
380
+ this.config.errorOutput(formatted);
381
+ } else {
382
+ this.config.output(formatted);
383
+ }
384
+ }
385
+ collectEnrichments() {
386
+ const result = {};
387
+ for (const enricher of _MomentumLogger.enrichers) {
388
+ Object.assign(result, enricher.enrich());
389
+ }
390
+ return result;
391
+ }
392
+ };
393
+ function isResolvedConfig(config) {
394
+ if (!config)
395
+ return false;
396
+ return typeof config.level === "string" && typeof config.format === "string" && typeof config.timestamps === "boolean" && // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- type guard narrows union
397
+ typeof config.output === "function" && // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- type guard narrows union
398
+ typeof config.errorOutput === "function";
399
+ }
400
+
401
+ // libs/logger/src/lib/logger-singleton.ts
402
+ var loggerInstance = null;
403
+ var ROOT_CONTEXT = "Momentum";
404
+ function getMomentumLogger() {
405
+ if (!loggerInstance) {
406
+ loggerInstance = new MomentumLogger(ROOT_CONTEXT);
407
+ }
408
+ return loggerInstance;
409
+ }
410
+ function createLogger(context) {
411
+ return getMomentumLogger().child(context);
412
+ }
413
+
414
+ // libs/auth/src/lib/auth.ts
415
+ function isLegacyConfig(config) {
416
+ return "database" in config && !("db" in config);
417
+ }
418
+ function buildSocialProviders(config, baseURL) {
419
+ const providers = {};
420
+ const resolvedBaseURL = baseURL ?? "http://localhost:4000";
421
+ const googleClientId = config?.google?.clientId ?? process.env["GOOGLE_CLIENT_ID"];
422
+ const googleClientSecret = config?.google?.clientSecret ?? process.env["GOOGLE_CLIENT_SECRET"];
423
+ if (googleClientId && googleClientSecret) {
424
+ providers["google"] = {
425
+ clientId: googleClientId,
426
+ clientSecret: googleClientSecret,
427
+ redirectURI: config?.google?.redirectURI ?? `${resolvedBaseURL}/api/auth/callback/google`
428
+ };
429
+ }
430
+ const githubClientId = config?.github?.clientId ?? process.env["GITHUB_CLIENT_ID"];
431
+ const githubClientSecret = config?.github?.clientSecret ?? process.env["GITHUB_CLIENT_SECRET"];
432
+ if (githubClientId && githubClientSecret) {
433
+ providers["github"] = {
434
+ clientId: githubClientId,
435
+ clientSecret: githubClientSecret,
436
+ redirectURI: config?.github?.redirectURI ?? `${resolvedBaseURL}/api/auth/callback/github`
437
+ };
438
+ }
439
+ return Object.keys(providers).length > 0 ? providers : void 0;
440
+ }
441
+ function getEnabledOAuthProviders(config) {
442
+ const providers = [];
443
+ const googleClientId = config?.google?.clientId ?? process.env["GOOGLE_CLIENT_ID"];
444
+ const googleClientSecret = config?.google?.clientSecret ?? process.env["GOOGLE_CLIENT_SECRET"];
445
+ if (googleClientId && googleClientSecret) {
446
+ providers.push("google");
447
+ }
448
+ const githubClientId = config?.github?.clientId ?? process.env["GITHUB_CLIENT_ID"];
449
+ const githubClientSecret = config?.github?.clientSecret ?? process.env["GITHUB_CLIENT_SECRET"];
450
+ if (githubClientId && githubClientSecret) {
451
+ providers.push("github");
452
+ }
453
+ return providers;
454
+ }
455
+ function convertFieldsToAdditionalFields(fields) {
456
+ const result = {};
457
+ for (const field of fields) {
458
+ let baType;
459
+ switch (field.type) {
460
+ case "checkbox":
461
+ baType = "boolean";
462
+ break;
463
+ case "number":
464
+ baType = "number";
465
+ break;
466
+ case "date":
467
+ baType = "string";
468
+ break;
469
+ default:
470
+ baType = "string";
471
+ break;
472
+ }
473
+ result[field.name] = {
474
+ type: baType,
475
+ required: field.required ?? false,
476
+ input: false
477
+ // Sub-plugin fields are not user-settable by default
478
+ };
479
+ }
480
+ return result;
481
+ }
482
+ function createMomentumAuth(config) {
483
+ const dbConfig = isLegacyConfig(config) ? { type: "sqlite", database: config.database } : config.db;
484
+ const {
485
+ baseURL,
486
+ secret,
487
+ trustedOrigins,
488
+ email: emailConfig,
489
+ socialProviders,
490
+ twoFactorAuth
491
+ } = config;
492
+ const extraPlugins = !isLegacyConfig(config) ? config.plugins ?? [] : [];
493
+ const extraUserFields = !isLegacyConfig(config) ? config.userFields ?? [] : [];
494
+ const databaseOption = dbConfig.type === "sqlite" ? dbConfig.database : dbConfig.pool;
495
+ const emailEnabled = emailConfig?.enabled ?? !!process.env["SMTP_HOST"];
496
+ const appName = emailConfig?.appName ?? "Momentum CMS";
497
+ let emailService = null;
498
+ if (emailEnabled) {
499
+ emailService = createEmailService(emailConfig);
500
+ }
501
+ const emailAndPasswordConfig = {
502
+ enabled: true,
503
+ minPasswordLength: 8
504
+ };
505
+ if (emailService) {
506
+ emailAndPasswordConfig.sendResetPassword = async ({ user, url }) => {
507
+ const { subject, text: text2, html } = getPasswordResetEmail({
508
+ name: user.name,
509
+ url,
510
+ appName,
511
+ expiresIn: "1 hour"
512
+ });
513
+ emailService.sendEmail({
514
+ to: user.email,
515
+ subject,
516
+ text: text2,
517
+ html
518
+ }).catch((err) => {
519
+ createLogger("Auth").error(
520
+ `Failed to send password reset email: ${err instanceof Error ? err.message : String(err)}`
521
+ );
522
+ });
523
+ };
524
+ }
525
+ const requireVerification = emailConfig?.requireEmailVerification ?? false;
526
+ const emailVerificationConfig = emailService ? {
527
+ sendOnSignUp: true,
528
+ autoSignInAfterVerification: true,
529
+ expiresIn: 86400,
530
+ // 24 hours
531
+ sendVerificationEmail: async ({
532
+ user,
533
+ url
534
+ }) => {
535
+ const { subject, text: text2, html } = getVerificationEmail({
536
+ name: user.name,
537
+ url,
538
+ appName,
539
+ expiresIn: "24 hours"
540
+ });
541
+ emailService.sendEmail({
542
+ to: user.email,
543
+ subject,
544
+ text: text2,
545
+ html
546
+ }).catch((err) => {
547
+ createLogger("Auth").error(
548
+ `Failed to send verification email: ${err instanceof Error ? err.message : String(err)}`
549
+ );
550
+ });
551
+ }
552
+ } : void 0;
553
+ if (requireVerification && emailAndPasswordConfig) {
554
+ emailAndPasswordConfig.requireEmailVerification = true;
555
+ }
556
+ const socialProvidersConfig = buildSocialProviders(socialProviders, baseURL);
557
+ const plugins = [];
558
+ if (twoFactorAuth) {
559
+ plugins.push(twoFactor());
560
+ }
561
+ for (const p of extraPlugins) {
562
+ if (p !== void 0) {
563
+ plugins.push(p);
564
+ }
565
+ }
566
+ return betterAuth({
567
+ database: databaseOption,
568
+ baseURL: baseURL ?? "http://localhost:4000",
569
+ secret: secret ?? process.env["AUTH_SECRET"] ?? "momentum-cms-dev-secret-change-in-production",
570
+ trustedOrigins: trustedOrigins ?? [baseURL ?? "http://localhost:4000"],
571
+ // Enable email/password authentication with optional password reset
572
+ emailAndPassword: emailAndPasswordConfig,
573
+ // Email verification (only if email is enabled)
574
+ ...emailVerificationConfig && { emailVerification: emailVerificationConfig },
575
+ // Social login providers (only if configured)
576
+ ...socialProvidersConfig && { socialProviders: socialProvidersConfig },
577
+ // Plugins (2FA, etc.)
578
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions -- Better Auth plugin types are opaque from sub-plugins
579
+ ...plugins.length > 0 && { plugins },
580
+ // Add custom role field to users + any extra user fields from sub-plugins.
581
+ // Base role is spread AFTER sub-plugin fields so it cannot be overwritten
582
+ // (a sub-plugin field named 'role' would lose defaultValue and input protection).
583
+ user: {
584
+ additionalFields: {
585
+ // Convert Momentum Field definitions to Better Auth additionalFields format
586
+ ...convertFieldsToAdditionalFields(extraUserFields.filter((f) => f.name !== "role")),
587
+ role: {
588
+ type: "string",
589
+ required: false,
590
+ defaultValue: "user",
591
+ input: false
592
+ // Don't allow users to set their own role
593
+ }
594
+ }
595
+ },
596
+ // Session configuration
597
+ // Note: cookieCache is intentionally disabled. It caches session data
598
+ // (including role) in a signed cookie, which causes stale role issues
599
+ // when roles are updated after session creation (e.g., setup flow).
600
+ session: {
601
+ expiresIn: 60 * 60 * 24 * 7,
602
+ // 7 days
603
+ updateAge: 60 * 60 * 24
604
+ // Update session every 24 hours
605
+ }
606
+ });
607
+ }
608
+
609
+ // libs/core/src/lib/collections/define-collection.ts
610
+ function defineCollection(config) {
611
+ const collection = {
612
+ timestamps: true,
613
+ // Enable timestamps by default
614
+ ...config
615
+ };
616
+ if (!collection.slug) {
617
+ throw new Error("Collection must have a slug");
618
+ }
619
+ if (!collection.fields || collection.fields.length === 0) {
620
+ throw new Error(`Collection "${collection.slug}" must have at least one field`);
621
+ }
622
+ if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
623
+ throw new Error(
624
+ `Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
625
+ );
626
+ }
627
+ return collection;
628
+ }
629
+
630
+ // libs/core/src/lib/fields/field-builders.ts
631
+ function text(name, options = {}) {
632
+ return {
633
+ name,
634
+ type: "text",
635
+ ...options
636
+ };
637
+ }
638
+ function number(name, options = {}) {
639
+ return {
640
+ name,
641
+ type: "number",
642
+ ...options
643
+ };
644
+ }
645
+ function date(name, options = {}) {
646
+ return {
647
+ name,
648
+ type: "date",
649
+ ...options
650
+ };
651
+ }
652
+ function checkbox(name, options = {}) {
653
+ return {
654
+ name,
655
+ type: "checkbox",
656
+ ...options,
657
+ defaultValue: options.defaultValue ?? false
658
+ };
659
+ }
660
+ function select(name, options) {
661
+ return {
662
+ name,
663
+ type: "select",
664
+ ...options
665
+ };
666
+ }
667
+ function email(name, options = {}) {
668
+ return {
669
+ name,
670
+ type: "email",
671
+ ...options
672
+ };
673
+ }
674
+ function relationship(name, options) {
675
+ return {
676
+ name,
677
+ type: "relationship",
678
+ ...options
679
+ };
680
+ }
681
+ function json(name, options = {}) {
682
+ return {
683
+ name,
684
+ type: "json",
685
+ ...options
686
+ };
687
+ }
688
+
689
+ // libs/core/src/lib/collections/media.collection.ts
690
+ var MediaCollection = defineCollection({
691
+ slug: "media",
692
+ labels: {
693
+ singular: "Media",
694
+ plural: "Media"
695
+ },
696
+ admin: {
697
+ useAsTitle: "filename",
698
+ defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
699
+ },
700
+ fields: [
701
+ text("filename", {
702
+ required: true,
703
+ label: "Filename",
704
+ description: "Original filename of the uploaded file"
705
+ }),
706
+ text("mimeType", {
707
+ required: true,
708
+ label: "MIME Type",
709
+ description: "File MIME type (e.g., image/jpeg, application/pdf)"
710
+ }),
711
+ number("filesize", {
712
+ label: "File Size",
713
+ description: "File size in bytes"
714
+ }),
715
+ text("path", {
716
+ required: true,
717
+ label: "Storage Path",
718
+ description: "Path/key where the file is stored",
719
+ admin: {
720
+ hidden: true
721
+ }
722
+ }),
723
+ text("url", {
724
+ label: "URL",
725
+ description: "Public URL to access the file"
726
+ }),
727
+ text("alt", {
728
+ label: "Alt Text",
729
+ description: "Alternative text for accessibility"
730
+ }),
731
+ number("width", {
732
+ label: "Width",
733
+ description: "Image width in pixels (for images only)"
734
+ }),
735
+ number("height", {
736
+ label: "Height",
737
+ description: "Image height in pixels (for images only)"
738
+ }),
739
+ json("focalPoint", {
740
+ label: "Focal Point",
741
+ description: "Focal point coordinates for image cropping",
742
+ admin: {
743
+ hidden: true
744
+ }
745
+ })
746
+ ],
747
+ access: {
748
+ // Media is readable by anyone by default
749
+ read: () => true,
750
+ // Only authenticated users can create/update/delete
751
+ create: ({ req }) => !!req?.user,
752
+ update: ({ req }) => !!req?.user,
753
+ delete: ({ req }) => !!req?.user
754
+ }
755
+ });
756
+
757
+ // libs/auth/src/lib/auth-collections.ts
758
+ var AUTH_ROLES = [
759
+ { label: "Admin", value: "admin" },
760
+ { label: "Editor", value: "editor" },
761
+ { label: "User", value: "user" },
762
+ { label: "Viewer", value: "viewer" }
763
+ ];
764
+ var AuthUserCollection = defineCollection({
765
+ slug: "auth-user",
766
+ dbName: "user",
767
+ timestamps: true,
768
+ labels: { singular: "User", plural: "Users" },
769
+ fields: [
770
+ text("name", { required: true }),
771
+ email("email", { required: true }),
772
+ checkbox("emailVerified"),
773
+ text("image"),
774
+ select("role", {
775
+ options: AUTH_ROLES,
776
+ defaultValue: "user"
777
+ })
778
+ ],
779
+ indexes: [{ columns: ["email"], unique: true }],
780
+ admin: {
781
+ group: "Authentication",
782
+ useAsTitle: "email",
783
+ defaultColumns: ["name", "email", "role", "createdAt"],
784
+ description: "Users authenticated via Better Auth"
785
+ },
786
+ access: {
787
+ admin: ({ req }) => req.user?.role === "admin",
788
+ read: ({ req }) => req.user?.role === "admin",
789
+ create: ({ req }) => req.user?.role === "admin",
790
+ update: ({ req }) => req.user?.role === "admin",
791
+ delete: ({ req }) => req.user?.role === "admin"
792
+ }
793
+ });
794
+ var AuthSessionCollection = defineCollection({
795
+ slug: "auth-session",
796
+ dbName: "session",
797
+ managed: true,
798
+ timestamps: true,
799
+ fields: [
800
+ text("userId", { required: true }),
801
+ text("token", { required: true }),
802
+ date("expiresAt", { required: true }),
803
+ text("ipAddress"),
804
+ text("userAgent")
805
+ ],
806
+ indexes: [{ columns: ["userId"] }, { columns: ["token"], unique: true }],
807
+ admin: {
808
+ group: "Authentication",
809
+ hidden: true,
810
+ description: "Active user sessions"
811
+ },
812
+ access: {
813
+ admin: ({ req }) => req.user?.role === "admin",
814
+ read: ({ req }) => req.user?.role === "admin",
815
+ create: () => false,
816
+ update: () => false,
817
+ delete: ({ req }) => req.user?.role === "admin"
818
+ }
819
+ });
820
+ var AuthAccountCollection = defineCollection({
821
+ slug: "auth-account",
822
+ dbName: "account",
823
+ managed: true,
824
+ timestamps: true,
825
+ fields: [
826
+ text("userId", { required: true }),
827
+ text("accountId", { required: true }),
828
+ text("providerId", { required: true }),
829
+ text("accessToken"),
830
+ text("refreshToken"),
831
+ date("accessTokenExpiresAt"),
832
+ date("refreshTokenExpiresAt"),
833
+ text("scope"),
834
+ text("idToken"),
835
+ text("password")
836
+ ],
837
+ indexes: [{ columns: ["userId"] }],
838
+ admin: {
839
+ group: "Authentication",
840
+ hidden: true,
841
+ description: "OAuth and credential accounts"
842
+ },
843
+ access: {
844
+ admin: ({ req }) => req.user?.role === "admin",
845
+ read: () => false,
846
+ // Never expose OAuth tokens/password hashes via API — Better Auth owns this data
847
+ create: () => false,
848
+ update: () => false,
849
+ delete: () => false
850
+ }
851
+ });
852
+ var AuthVerificationCollection = defineCollection({
853
+ slug: "auth-verification",
854
+ dbName: "verification",
855
+ managed: true,
856
+ timestamps: true,
857
+ fields: [
858
+ text("identifier", { required: true }),
859
+ text("value", { required: true }),
860
+ date("expiresAt", { required: true })
861
+ ],
862
+ admin: {
863
+ group: "Authentication",
864
+ hidden: true,
865
+ description: "Email verification and password reset tokens"
866
+ },
867
+ access: {
868
+ admin: ({ req }) => req.user?.role === "admin",
869
+ read: () => false,
870
+ create: () => false,
871
+ update: () => false,
872
+ delete: () => false
873
+ }
874
+ });
875
+ var AuthApiKeysCollection = defineCollection({
876
+ slug: "auth-api-keys",
877
+ dbName: "_api_keys",
878
+ timestamps: true,
879
+ fields: [
880
+ text("name", { required: true }),
881
+ text("keyHash", { required: true, admin: { hidden: true }, access: { read: () => false } }),
882
+ text("keyPrefix", { required: true }),
883
+ relationship("createdBy", {
884
+ required: true,
885
+ collection: () => AuthUserCollection,
886
+ label: "Created By"
887
+ }),
888
+ select("role", {
889
+ options: AUTH_ROLES,
890
+ defaultValue: "user"
891
+ }),
892
+ date("expiresAt"),
893
+ date("lastUsedAt")
894
+ ],
895
+ indexes: [{ columns: ["keyHash"], unique: true }, { columns: ["createdBy"] }],
896
+ admin: {
897
+ group: "Authentication",
898
+ useAsTitle: "name",
899
+ defaultColumns: ["name", "keyPrefix", "role", "createdBy", "createdAt", "lastUsedAt"],
900
+ description: "API keys for programmatic access",
901
+ headerActions: [
902
+ { id: "generate-key", label: "Generate API Key", endpoint: "/api/auth/api-keys" }
903
+ ]
904
+ },
905
+ access: {
906
+ admin: ({ req }) => !!req.user,
907
+ read: ({ req }) => !!req.user,
908
+ create: () => false,
909
+ // API keys must be created through dedicated /api/auth/api-keys endpoint
910
+ update: () => false,
911
+ delete: () => false
912
+ // Deletion only via dedicated /api/auth/api-keys/:id (has ownership checks)
913
+ },
914
+ defaultWhere: (req) => {
915
+ if (!req.user)
916
+ return { createdBy: "__none__" };
917
+ if (req.user.role === "admin")
918
+ return void 0;
919
+ return { createdBy: req.user.id };
920
+ }
921
+ });
922
+ var BASE_AUTH_COLLECTIONS = [
923
+ AuthUserCollection,
924
+ AuthSessionCollection,
925
+ AuthAccountCollection,
926
+ AuthVerificationCollection,
927
+ AuthApiKeysCollection
928
+ ];
929
+
930
+ // libs/auth/src/lib/auth-plugin.ts
931
+ function momentumAuth(config) {
932
+ let authInstance = null;
933
+ const subPlugins = config.plugins ?? [];
934
+ const allCollections = [];
935
+ const allUserFields = [...config.userFields ?? []];
936
+ const allSessionFields = [];
937
+ const allBetterAuthPlugins = [];
938
+ for (const sp of subPlugins) {
939
+ if (sp.collections)
940
+ allCollections.push(...sp.collections);
941
+ if (sp.userFields)
942
+ allUserFields.push(...sp.userFields);
943
+ if (sp.sessionFields)
944
+ allSessionFields.push(...sp.sessionFields);
945
+ if (sp.betterAuthPlugin !== void 0)
946
+ allBetterAuthPlugins.push(sp.betterAuthPlugin);
947
+ }
948
+ const authUserWithFields = {
949
+ ...AuthUserCollection,
950
+ fields: [...AuthUserCollection.fields, ...allUserFields]
951
+ };
952
+ const authSessionWithFields = {
953
+ ...AuthSessionCollection,
954
+ fields: [...AuthSessionCollection.fields, ...allSessionFields]
955
+ };
956
+ const finalAuthCollections = [
957
+ authUserWithFields,
958
+ authSessionWithFields,
959
+ // All base collections except user and session (which we replaced above)
960
+ ...BASE_AUTH_COLLECTIONS.filter((c) => c.slug !== "auth-user" && c.slug !== "auth-session"),
961
+ // Sub-plugin collections
962
+ ...allCollections
963
+ ];
964
+ const showInAdmin = config.admin?.showCollections ?? true;
965
+ if (!showInAdmin) {
966
+ for (const c of finalAuthCollections) {
967
+ c.admin = { ...c.admin, hidden: true };
968
+ }
969
+ }
970
+ return {
971
+ name: "momentum-auth",
972
+ // Static collections for admin UI route data (read at config time)
973
+ collections: finalAuthCollections,
974
+ getAuth() {
975
+ if (!authInstance) {
976
+ throw new Error("Auth not initialized. Call onInit first (via initializeMomentum).");
977
+ }
978
+ return authInstance;
979
+ },
980
+ tryGetAuth() {
981
+ return authInstance;
982
+ },
983
+ getPluginConfig() {
984
+ return {
985
+ db: config.db,
986
+ socialProviders: config.socialProviders
987
+ };
988
+ },
989
+ async onInit(context) {
990
+ const { logger } = context;
991
+ context.collections.push(...finalAuthCollections);
992
+ logger.info(`Injected ${finalAuthCollections.length} auth collections`);
993
+ authInstance = createMomentumAuth({
994
+ db: config.db,
995
+ baseURL: config.baseURL,
996
+ secret: config.secret,
997
+ trustedOrigins: config.trustedOrigins,
998
+ email: config.email,
999
+ socialProviders: config.socialProviders,
1000
+ plugins: allBetterAuthPlugins,
1001
+ userFields: allUserFields
1002
+ });
1003
+ logger.info("Better Auth instance created");
1004
+ }
1005
+ };
1006
+ }
1007
+
1008
+ // libs/auth/src/lib/plugins/two-factor.ts
1009
+ import { twoFactor as twoFactor2 } from "better-auth/plugins";
1010
+ var AuthTwoFactorCollection = defineCollection({
1011
+ slug: "auth-two-factor",
1012
+ dbName: "twoFactor",
1013
+ managed: true,
1014
+ timestamps: false,
1015
+ fields: [
1016
+ text("secret", { required: true }),
1017
+ text("backupCodes", { required: true }),
1018
+ text("userId", { required: true })
1019
+ ],
1020
+ indexes: [{ columns: ["secret"] }, { columns: ["userId"] }],
1021
+ admin: {
1022
+ group: "Authentication",
1023
+ hidden: true,
1024
+ description: "Two-factor authentication secrets"
1025
+ },
1026
+ access: {
1027
+ read: () => false,
1028
+ create: () => false,
1029
+ update: () => false,
1030
+ delete: () => false
1031
+ }
1032
+ });
1033
+ function authTwoFactor() {
1034
+ return {
1035
+ name: "two-factor",
1036
+ betterAuthPlugin: twoFactor2(),
1037
+ collections: [AuthTwoFactorCollection],
1038
+ userFields: [checkbox("twoFactorEnabled")]
1039
+ };
1040
+ }
1041
+
1042
+ // libs/auth/src/lib/plugins/admin.ts
1043
+ function authAdmin() {
1044
+ return {
1045
+ name: "admin",
1046
+ // Stub: Better Auth admin plugin will be added here
1047
+ betterAuthPlugin: void 0,
1048
+ userFields: [checkbox("banned"), text("banReason"), date("banExpires")],
1049
+ sessionFields: [text("impersonatedBy")]
1050
+ };
1051
+ }
1052
+
1053
+ // libs/auth/src/lib/plugins/organization.ts
1054
+ var AuthOrganizationCollection = defineCollection({
1055
+ slug: "auth-organization",
1056
+ dbName: "organization",
1057
+ managed: true,
1058
+ timestamps: true,
1059
+ fields: [
1060
+ text("name", { required: true }),
1061
+ text("slug", { required: true }),
1062
+ text("logo"),
1063
+ text("metadata")
1064
+ ],
1065
+ indexes: [{ columns: ["slug"], unique: true }],
1066
+ admin: {
1067
+ group: "Authentication",
1068
+ useAsTitle: "name",
1069
+ description: "Organizations for multi-tenant access"
1070
+ },
1071
+ access: {
1072
+ read: ({ req }) => req.user?.role === "admin",
1073
+ create: ({ req }) => req.user?.role === "admin",
1074
+ update: ({ req }) => req.user?.role === "admin",
1075
+ delete: ({ req }) => req.user?.role === "admin"
1076
+ }
1077
+ });
1078
+ var AuthMemberCollection = defineCollection({
1079
+ slug: "auth-member",
1080
+ dbName: "member",
1081
+ managed: true,
1082
+ timestamps: true,
1083
+ fields: [
1084
+ text("userId", { required: true }),
1085
+ text("organizationId", { required: true }),
1086
+ select("role", {
1087
+ options: [
1088
+ { label: "Owner", value: "owner" },
1089
+ { label: "Admin", value: "admin" },
1090
+ { label: "Member", value: "member" }
1091
+ ],
1092
+ defaultValue: "member"
1093
+ })
1094
+ ],
1095
+ indexes: [
1096
+ { columns: ["userId"] },
1097
+ { columns: ["organizationId"] },
1098
+ { columns: ["userId", "organizationId"], unique: true }
1099
+ ],
1100
+ admin: {
1101
+ group: "Authentication",
1102
+ hidden: true,
1103
+ description: "Organization membership"
1104
+ },
1105
+ access: {
1106
+ read: ({ req }) => req.user?.role === "admin",
1107
+ create: () => false,
1108
+ update: () => false,
1109
+ delete: () => false
1110
+ }
1111
+ });
1112
+ var AuthInvitationCollection = defineCollection({
1113
+ slug: "auth-invitation",
1114
+ dbName: "invitation",
1115
+ managed: true,
1116
+ timestamps: true,
1117
+ fields: [
1118
+ text("email", { required: true }),
1119
+ text("organizationId", { required: true }),
1120
+ text("inviterId", { required: true }),
1121
+ select("role", {
1122
+ options: [
1123
+ { label: "Admin", value: "admin" },
1124
+ { label: "Member", value: "member" }
1125
+ ],
1126
+ defaultValue: "member"
1127
+ }),
1128
+ select("status", {
1129
+ options: [
1130
+ { label: "Pending", value: "pending" },
1131
+ { label: "Accepted", value: "accepted" },
1132
+ { label: "Rejected", value: "rejected" },
1133
+ { label: "Cancelled", value: "cancelled" }
1134
+ ],
1135
+ defaultValue: "pending"
1136
+ }),
1137
+ date("expiresAt", { required: true })
1138
+ ],
1139
+ indexes: [{ columns: ["organizationId"] }, { columns: ["email"] }],
1140
+ admin: {
1141
+ group: "Authentication",
1142
+ hidden: true,
1143
+ description: "Pending organization invitations"
1144
+ },
1145
+ access: {
1146
+ read: ({ req }) => req.user?.role === "admin",
1147
+ create: () => false,
1148
+ update: () => false,
1149
+ delete: () => false
1150
+ }
1151
+ });
1152
+ function authOrganization() {
1153
+ return {
1154
+ name: "organization",
1155
+ // Stub: Better Auth organization plugin will be added here
1156
+ betterAuthPlugin: void 0,
1157
+ collections: [AuthOrganizationCollection, AuthMemberCollection, AuthInvitationCollection]
1158
+ };
1159
+ }
1160
+ export {
1161
+ AuthAccountCollection,
1162
+ AuthApiKeysCollection,
1163
+ AuthSessionCollection,
1164
+ AuthUserCollection,
1165
+ AuthVerificationCollection,
1166
+ BASE_AUTH_COLLECTIONS,
1167
+ authAdmin,
1168
+ authOrganization,
1169
+ authTwoFactor,
1170
+ createEmailService,
1171
+ createMomentumAuth,
1172
+ getEnabledOAuthProviders,
1173
+ getPasswordResetEmail,
1174
+ getVerificationEmail,
1175
+ momentumAuth
1176
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momentumcms/auth",
3
- "version": "0.0.1",
3
+ "version": "0.1.2",
4
4
  "description": "Better Auth integration for Momentum CMS",
5
5
  "license": "MIT",
6
6
  "author": "Momentum CMS Contributors",
@@ -23,7 +23,6 @@
23
23
  "engines": {
24
24
  "node": ">=18"
25
25
  },
26
- "type": "commonjs",
27
26
  "main": "./index.cjs",
28
27
  "types": "./src/index.d.ts",
29
28
  "peerDependencies": {
@@ -33,5 +32,6 @@
33
32
  "better-sqlite3": "^12.0.0",
34
33
  "nodemailer": "^8.0.0",
35
34
  "pg": "^8.0.0"
36
- }
35
+ },
36
+ "module": "./index.js"
37
37
  }