@snapback/cli 1.1.12 → 1.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -18
- package/dist/SkippedTestDetector-AXTMWWHC.js +5 -0
- package/dist/SkippedTestDetector-QLSQV7K7.js +5 -0
- package/dist/analysis-6WTBZJH3.js +6 -0
- package/dist/analysis-C472LUGW.js +2475 -0
- package/dist/auth-HFJRXXG2.js +1446 -0
- package/dist/auto-provision-organization-SF6XM7X4.js +161 -0
- package/dist/chunk-23G5VYA3.js +4259 -0
- package/dist/{chunk-QAKFE3NE.js → chunk-4YTE4JEW.js} +3 -4
- package/dist/chunk-5EOPYJ4Y.js +12 -0
- package/dist/{chunk-G7QXHNGB.js → chunk-5SQA44V7.js} +1125 -32
- package/dist/{chunk-BW7RALUZ.js → chunk-7ADPL4Q3.js} +11 -4
- package/dist/chunk-CBGOC6RV.js +293 -0
- package/dist/chunk-DNEADD2G.js +3499 -0
- package/dist/{chunk-NKBZIXCN.js → chunk-DPWFZNMY.js} +122 -15
- package/dist/chunk-GQ73B37K.js +314 -0
- package/dist/chunk-HR34NJP7.js +6133 -0
- package/dist/chunk-ICKSHS3A.js +2264 -0
- package/dist/{chunk-KPETDXQO.js → chunk-OI2HNNT6.js} +565 -50
- package/dist/chunk-PL4HF4M2.js +593 -0
- package/dist/chunk-WS36HDEU.js +3735 -0
- package/dist/chunk-XYU5FFE3.js +111 -0
- package/dist/chunk-ZBQDE6WJ.js +108 -0
- package/dist/client-WIO6W447.js +8 -0
- package/dist/dist-E7E2T3DQ.js +9 -0
- package/dist/dist-TEWNOZYS.js +5 -0
- package/dist/dist-YZBJAYEJ.js +12 -0
- package/dist/index.js +65215 -26627
- package/dist/local-service-adapter-3JHN6G4O.js +6 -0
- package/dist/pioneer-oauth-hook-V2JKEXM7.js +12 -0
- package/dist/{secure-credentials-6UMEU22H.js → secure-credentials-UEPG7GWW.js} +15 -8
- package/dist/snapback-dir-MG7DTRMF.js +6 -0
- package/package.json +8 -42
- package/scripts/postinstall.mjs +2 -3
- package/dist/SkippedTestDetector-B3JZUE5G.js +0 -5
- package/dist/SkippedTestDetector-B3JZUE5G.js.map +0 -1
- package/dist/analysis-Z53F5FT2.js +0 -6
- package/dist/analysis-Z53F5FT2.js.map +0 -1
- package/dist/chunk-6MR2TINI.js +0 -27
- package/dist/chunk-6MR2TINI.js.map +0 -1
- package/dist/chunk-BW7RALUZ.js.map +0 -1
- package/dist/chunk-G7QXHNGB.js.map +0 -1
- package/dist/chunk-ISVRGBWT.js +0 -16223
- package/dist/chunk-ISVRGBWT.js.map +0 -1
- package/dist/chunk-KPETDXQO.js.map +0 -1
- package/dist/chunk-NKBZIXCN.js.map +0 -1
- package/dist/chunk-QAKFE3NE.js.map +0 -1
- package/dist/chunk-YOVA65PS.js +0 -12745
- package/dist/chunk-YOVA65PS.js.map +0 -1
- package/dist/dist-7UKXVKH3.js +0 -5
- package/dist/dist-7UKXVKH3.js.map +0 -1
- package/dist/dist-VDK7WEF4.js +0 -5
- package/dist/dist-VDK7WEF4.js.map +0 -1
- package/dist/dist-WKLJSPJT.js +0 -8
- package/dist/dist-WKLJSPJT.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/secure-credentials-6UMEU22H.js.map +0 -1
- package/dist/snapback-dir-T3CRQRY6.js +0 -6
- package/dist/snapback-dir-T3CRQRY6.js.map +0 -1
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
#!/usr/bin/env node --no-warnings=ExperimentalWarning
|
|
2
|
+
import { config, EntitlementsServiceImpl, getBaseUrl, ENABLE_ENHANCED_2FA, ENABLE_SSO, ENABLE_CAPTCHA, ENABLE_MULTI_SESSION, getOrganizationWithPurchasesAndMembersCount, getPendingInvitationByEmail } from './chunk-23G5VYA3.js';
|
|
3
|
+
import './chunk-GQ73B37K.js';
|
|
4
|
+
import { combinedSchema, db } from './chunk-HR34NJP7.js';
|
|
5
|
+
import { trackEvent } from './chunk-XYU5FFE3.js';
|
|
6
|
+
import './chunk-ICKSHS3A.js';
|
|
7
|
+
import { logger } from './chunk-PL4HF4M2.js';
|
|
8
|
+
import { createLogger, LogLevel } from './chunk-WS36HDEU.js';
|
|
9
|
+
import './chunk-5EOPYJ4Y.js';
|
|
10
|
+
import './chunk-CBGOC6RV.js';
|
|
11
|
+
import { __name } from './chunk-7ADPL4Q3.js';
|
|
12
|
+
import { passkey } from '@better-auth/passkey';
|
|
13
|
+
import { sso } from '@better-auth/sso';
|
|
14
|
+
import { render } from '@react-email/render';
|
|
15
|
+
import { betterAuth } from 'better-auth';
|
|
16
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
17
|
+
import { bearer, anonymous, deviceAuthorization, admin as admin$1, apiKey, jwt, magicLink, openAPI, organization, haveIBeenPwned, twoFactor, username, captcha, multiSession, createAuthMiddleware } from 'better-auth/plugins';
|
|
18
|
+
import 'cookie';
|
|
19
|
+
import { nanoid } from 'nanoid';
|
|
20
|
+
import { createAccessControl } from 'better-auth/plugins/access';
|
|
21
|
+
import { APIError } from 'better-auth/api';
|
|
22
|
+
import 'drizzle-orm';
|
|
23
|
+
import Stripe from 'stripe';
|
|
24
|
+
|
|
25
|
+
process.env.SNAPBACK_CLI='true';
|
|
26
|
+
|
|
27
|
+
// ../../packages/config/dist/client.js
|
|
28
|
+
config.payments.plans;
|
|
29
|
+
|
|
30
|
+
// ../../packages/integrations/dist/email/handlers/billing-events.js
|
|
31
|
+
(class {
|
|
32
|
+
static {
|
|
33
|
+
__name(this, "BillingEmailHandler");
|
|
34
|
+
}
|
|
35
|
+
emailService;
|
|
36
|
+
constructor(emailService2) {
|
|
37
|
+
this.emailService = emailService2;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Send welcome email after successful subscription
|
|
41
|
+
*/
|
|
42
|
+
async sendWelcome(params) {
|
|
43
|
+
const request = {
|
|
44
|
+
to: {
|
|
45
|
+
email: params.to,
|
|
46
|
+
userId: params.userId
|
|
47
|
+
},
|
|
48
|
+
category: "onboarding",
|
|
49
|
+
priority: "high",
|
|
50
|
+
template: {
|
|
51
|
+
id: "product.welcome",
|
|
52
|
+
props: {
|
|
53
|
+
firstName: params.firstName,
|
|
54
|
+
planName: params.planName,
|
|
55
|
+
planFeatures: params.planFeatures,
|
|
56
|
+
dashboardUrl: params.dashboardUrl,
|
|
57
|
+
docsUrl: params.docsUrl,
|
|
58
|
+
pioneerTier: params.pioneerTier,
|
|
59
|
+
pioneerPoints: params.pioneerPoints
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
await this.emailService.send(request);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Send subscription cancellation confirmation
|
|
67
|
+
*/
|
|
68
|
+
async sendCancellationConfirmation(params) {
|
|
69
|
+
const request = {
|
|
70
|
+
to: {
|
|
71
|
+
email: params.to,
|
|
72
|
+
userId: params.userId
|
|
73
|
+
},
|
|
74
|
+
category: "billing",
|
|
75
|
+
priority: "high",
|
|
76
|
+
template: {
|
|
77
|
+
id: "product.system-alert",
|
|
78
|
+
props: {
|
|
79
|
+
alertType: "subscription_cancelled",
|
|
80
|
+
title: "Subscription Cancelled",
|
|
81
|
+
message: `Your ${params.planName} subscription has been cancelled and will remain active until ${params.cancellationDate.toLocaleDateString()}. Your data will be retained for ${params.dataRetentionDays} days after that date.`,
|
|
82
|
+
severity: "info",
|
|
83
|
+
actionRequired: false,
|
|
84
|
+
additionalDetails: "You can reactivate your subscription at any time before the retention period ends.",
|
|
85
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
await this.emailService.send(request);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Send payment failure alert
|
|
93
|
+
*/
|
|
94
|
+
async sendPaymentFailed(params) {
|
|
95
|
+
const request = {
|
|
96
|
+
to: {
|
|
97
|
+
email: params.to,
|
|
98
|
+
userId: params.userId
|
|
99
|
+
},
|
|
100
|
+
category: "billing",
|
|
101
|
+
priority: "critical",
|
|
102
|
+
template: {
|
|
103
|
+
id: "product.system-alert",
|
|
104
|
+
props: {
|
|
105
|
+
alertType: "payment_failed",
|
|
106
|
+
title: "Payment Failed",
|
|
107
|
+
message: `We were unable to process your payment of ${params.amount} ${params.currency} for your ${params.planName} subscription. We'll automatically retry on ${params.retryDate.toLocaleDateString()}.`,
|
|
108
|
+
severity: "warning",
|
|
109
|
+
actionRequired: true,
|
|
110
|
+
actionUrl: params.updatePaymentUrl,
|
|
111
|
+
actionLabel: "Update Payment Method",
|
|
112
|
+
additionalDetails: "To avoid service interruption, please update your payment method or ensure sufficient funds are available.",
|
|
113
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
await this.emailService.send(request);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Send subscription upgraded notification
|
|
121
|
+
*/
|
|
122
|
+
async sendSubscriptionUpgraded(params) {
|
|
123
|
+
const request = {
|
|
124
|
+
to: {
|
|
125
|
+
email: params.to,
|
|
126
|
+
userId: params.userId
|
|
127
|
+
},
|
|
128
|
+
category: "billing",
|
|
129
|
+
priority: "normal",
|
|
130
|
+
template: {
|
|
131
|
+
id: "product.system-alert",
|
|
132
|
+
props: {
|
|
133
|
+
alertType: "subscription_upgraded",
|
|
134
|
+
title: "Subscription Upgraded",
|
|
135
|
+
message: `You've successfully upgraded from ${params.oldPlan} to ${params.newPlan}. Your new features are now active!`,
|
|
136
|
+
severity: "info",
|
|
137
|
+
actionRequired: false,
|
|
138
|
+
actionUrl: params.dashboardUrl,
|
|
139
|
+
actionLabel: "Explore New Features",
|
|
140
|
+
additionalDetails: `New features: ${params.newFeatures.join(", ")}`,
|
|
141
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
await this.emailService.send(request);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Send trial ending reminder
|
|
149
|
+
*/
|
|
150
|
+
async sendTrialEnding(params) {
|
|
151
|
+
const request = {
|
|
152
|
+
to: {
|
|
153
|
+
email: params.to,
|
|
154
|
+
userId: params.userId
|
|
155
|
+
},
|
|
156
|
+
category: "billing",
|
|
157
|
+
priority: "high",
|
|
158
|
+
template: {
|
|
159
|
+
id: "product.system-alert",
|
|
160
|
+
props: {
|
|
161
|
+
alertType: "trial_ending",
|
|
162
|
+
title: `Your ${params.planName} Trial Ends Soon`,
|
|
163
|
+
message: `Your free trial ends in ${params.daysRemaining} day${params.daysRemaining !== 1 ? "s" : ""} on ${params.trialEndDate.toLocaleDateString()}. Upgrade now to keep your data and continue using SnapBack.`,
|
|
164
|
+
severity: "warning",
|
|
165
|
+
actionRequired: true,
|
|
166
|
+
actionUrl: params.upgradeUrl,
|
|
167
|
+
actionLabel: "Upgrade Now",
|
|
168
|
+
additionalDetails: "After your trial ends, you can still access your account but snapshot capture will be disabled until you upgrade.",
|
|
169
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
await this.emailService.send(request);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Send suspension notice (Day 8 of grace period)
|
|
177
|
+
*
|
|
178
|
+
* Called when subscription transitions from past_due to suspended
|
|
179
|
+
* after 7-day grace period expires without successful payment.
|
|
180
|
+
*/
|
|
181
|
+
async sendSuspensionNotice(params) {
|
|
182
|
+
const request = {
|
|
183
|
+
to: {
|
|
184
|
+
email: params.to,
|
|
185
|
+
userId: params.userId
|
|
186
|
+
},
|
|
187
|
+
category: "billing",
|
|
188
|
+
priority: "critical",
|
|
189
|
+
template: {
|
|
190
|
+
id: "product.system-alert",
|
|
191
|
+
props: {
|
|
192
|
+
alertType: "subscription_suspended",
|
|
193
|
+
title: "Your Subscription Has Been Suspended",
|
|
194
|
+
message: `Your ${params.planName} subscription has been suspended due to payment issues. Cloud features including snapshot sync, pattern library, and history are now paused.`,
|
|
195
|
+
severity: "error",
|
|
196
|
+
actionRequired: true,
|
|
197
|
+
actionUrl: params.reactivateUrl,
|
|
198
|
+
actionLabel: "Reactivate Now",
|
|
199
|
+
additionalDetails: "Update your payment method to restore full access. Your local CLI features remain available, but cloud syncing is disabled until payment is resolved.",
|
|
200
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
await this.emailService.send(request);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ../../packages/integrations/dist/email/provider/resend.js
|
|
209
|
+
var from = process.env.MAIL_FROM || "noreply@snapback.dev";
|
|
210
|
+
var send = /* @__PURE__ */ __name(async ({ to, subject, html }) => {
|
|
211
|
+
logger.info("\u{1F4E7} Resend: Sending email", {
|
|
212
|
+
to,
|
|
213
|
+
subject,
|
|
214
|
+
from
|
|
215
|
+
});
|
|
216
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: {
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
Authorization: `Bearer ${process.env.RESEND_API_KEY}`
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
from,
|
|
224
|
+
to,
|
|
225
|
+
subject,
|
|
226
|
+
html
|
|
227
|
+
})
|
|
228
|
+
});
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
const errorData = await response.json();
|
|
231
|
+
logger.error("\u274C Resend: Email send failed", {
|
|
232
|
+
status: response.status,
|
|
233
|
+
statusText: response.statusText,
|
|
234
|
+
error: errorData,
|
|
235
|
+
to,
|
|
236
|
+
subject
|
|
237
|
+
});
|
|
238
|
+
throw new Error("Could not send email");
|
|
239
|
+
}
|
|
240
|
+
const data = await response.json();
|
|
241
|
+
logger.info("\u2705 Resend: Email sent successfully", {
|
|
242
|
+
to,
|
|
243
|
+
subject,
|
|
244
|
+
emailId: data.id
|
|
245
|
+
});
|
|
246
|
+
}, "send");
|
|
247
|
+
(class {
|
|
248
|
+
static {
|
|
249
|
+
__name(this, "EmailService");
|
|
250
|
+
}
|
|
251
|
+
middlewares = [];
|
|
252
|
+
templateRegistry;
|
|
253
|
+
providers = /* @__PURE__ */ new Map();
|
|
254
|
+
/**
|
|
255
|
+
* Add middleware to the processing chain
|
|
256
|
+
*/
|
|
257
|
+
use(middleware) {
|
|
258
|
+
this.middlewares.push(middleware);
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Primary send method with composable middleware
|
|
263
|
+
*/
|
|
264
|
+
async send(request) {
|
|
265
|
+
if (request.dryRun) {
|
|
266
|
+
return this.handleDryRun(request);
|
|
267
|
+
}
|
|
268
|
+
const execute = this.middlewares.reduceRight((next, middleware) => () => middleware(request, next), () => this.executeDelivery(request));
|
|
269
|
+
return execute();
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Template-based convenience method
|
|
273
|
+
*/
|
|
274
|
+
async sendFromTemplate(options) {
|
|
275
|
+
const template = await this.templateRegistry?.get(options.templateId);
|
|
276
|
+
if (!template) {
|
|
277
|
+
return {
|
|
278
|
+
success: false,
|
|
279
|
+
error: `Template not found: ${options.templateId}`
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const validation = template.schema.safeParse(options.context);
|
|
283
|
+
if (!validation.success) {
|
|
284
|
+
return {
|
|
285
|
+
success: false,
|
|
286
|
+
error: `Invalid template context: ${validation.error.message}`
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const category = template.category;
|
|
290
|
+
const priority = this.getPriorityForCategory(category);
|
|
291
|
+
return this.send({
|
|
292
|
+
to: options.to,
|
|
293
|
+
provider: "auto",
|
|
294
|
+
category,
|
|
295
|
+
priority,
|
|
296
|
+
template: {
|
|
297
|
+
id: options.templateId,
|
|
298
|
+
props: validation.data
|
|
299
|
+
},
|
|
300
|
+
metadata: options.metadata
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Batch sending for digests
|
|
305
|
+
*/
|
|
306
|
+
async sendBatch(requests) {
|
|
307
|
+
const results = await Promise.allSettled(requests.map((request) => this.send(request)));
|
|
308
|
+
const succeeded = results.filter((r) => r.status === "fulfilled" && r.value.success).length;
|
|
309
|
+
return {
|
|
310
|
+
total: requests.length,
|
|
311
|
+
succeeded,
|
|
312
|
+
failed: requests.length - succeeded,
|
|
313
|
+
results: results.map((r) => r.status === "fulfilled" ? r.value : {
|
|
314
|
+
success: false,
|
|
315
|
+
error: "Promise rejected"
|
|
316
|
+
})
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Execute email delivery (called after middleware chain)
|
|
321
|
+
*/
|
|
322
|
+
async executeDelivery(request) {
|
|
323
|
+
try {
|
|
324
|
+
const template = await this.templateRegistry?.get(request.template.id);
|
|
325
|
+
if (!template) {
|
|
326
|
+
throw new Error(`Template not found: ${request.template.id}`);
|
|
327
|
+
}
|
|
328
|
+
const html = await render(template.component(request.template.props));
|
|
329
|
+
const text = await render(template.component(request.template.props), {
|
|
330
|
+
plainText: true
|
|
331
|
+
});
|
|
332
|
+
const provider = this.selectProvider(request);
|
|
333
|
+
const result = await provider.send({
|
|
334
|
+
from: "SnapBack <noreply@snapback.dev>",
|
|
335
|
+
to: request.to.email,
|
|
336
|
+
subject: template.subject(request.template.props),
|
|
337
|
+
html,
|
|
338
|
+
text,
|
|
339
|
+
metadata: request.metadata
|
|
340
|
+
});
|
|
341
|
+
await this.trackDelivery({
|
|
342
|
+
userId: request.to.userId,
|
|
343
|
+
templateId: request.template.id,
|
|
344
|
+
category: request.category,
|
|
345
|
+
provider: request.provider || "resend",
|
|
346
|
+
recipientEmail: request.to.email,
|
|
347
|
+
subject: template.subject(request.template.props),
|
|
348
|
+
status: result.status,
|
|
349
|
+
emailId: result.id,
|
|
350
|
+
metadata: request.metadata
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
success: result.status === "sent",
|
|
354
|
+
emailId: result.id,
|
|
355
|
+
error: result.error
|
|
356
|
+
};
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Handle dry-run mode (no actual email sent)
|
|
366
|
+
*/
|
|
367
|
+
async handleDryRun(request) {
|
|
368
|
+
console.log("\u{1F50D} DRY RUN MODE - Email not sent");
|
|
369
|
+
console.log(" Template:", request.template.id);
|
|
370
|
+
console.log(" To:", request.to.email);
|
|
371
|
+
console.log(" Category:", request.category);
|
|
372
|
+
console.log(" Priority:", request.priority);
|
|
373
|
+
return {
|
|
374
|
+
success: true,
|
|
375
|
+
emailId: `dry-run-${Date.now()}`
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Select provider based on category or explicit request
|
|
380
|
+
*/
|
|
381
|
+
selectProvider(request) {
|
|
382
|
+
if (request.provider && request.provider !== "auto") {
|
|
383
|
+
return this.providers.get(request.provider);
|
|
384
|
+
}
|
|
385
|
+
const provider = request.category === "marketing" ? "hubspot" : "resend";
|
|
386
|
+
return this.providers.get(provider);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Get default priority for email category
|
|
390
|
+
*/
|
|
391
|
+
getPriorityForCategory(category) {
|
|
392
|
+
const priorityMap = {
|
|
393
|
+
authentication: "critical",
|
|
394
|
+
billing: "high",
|
|
395
|
+
onboarding: "high",
|
|
396
|
+
product: "normal",
|
|
397
|
+
marketing: "low"
|
|
398
|
+
};
|
|
399
|
+
return priorityMap[category] || "normal";
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Track email delivery in database
|
|
403
|
+
*/
|
|
404
|
+
async trackDelivery(_data) {
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Register email provider
|
|
408
|
+
*/
|
|
409
|
+
registerProvider(name, provider) {
|
|
410
|
+
this.providers.set(name, provider);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Set template registry
|
|
414
|
+
*/
|
|
415
|
+
setTemplateRegistry(registry) {
|
|
416
|
+
this.templateRegistry = registry;
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
var statement = {
|
|
420
|
+
// Snapshot management permissions
|
|
421
|
+
snapshot: [
|
|
422
|
+
"create",
|
|
423
|
+
"read",
|
|
424
|
+
"update",
|
|
425
|
+
"delete",
|
|
426
|
+
"restore"
|
|
427
|
+
],
|
|
428
|
+
// API key management permissions
|
|
429
|
+
apiKey: [
|
|
430
|
+
"create",
|
|
431
|
+
"read",
|
|
432
|
+
"revoke"
|
|
433
|
+
],
|
|
434
|
+
// Member management permissions
|
|
435
|
+
member: [
|
|
436
|
+
"invite",
|
|
437
|
+
"remove",
|
|
438
|
+
"update"
|
|
439
|
+
],
|
|
440
|
+
// Organization settings permissions
|
|
441
|
+
organization: [
|
|
442
|
+
"read",
|
|
443
|
+
"update",
|
|
444
|
+
"delete"
|
|
445
|
+
],
|
|
446
|
+
// Billing and subscription permissions
|
|
447
|
+
billing: [
|
|
448
|
+
"read",
|
|
449
|
+
"update"
|
|
450
|
+
],
|
|
451
|
+
// Analytics and reporting permissions
|
|
452
|
+
analytics: [
|
|
453
|
+
"read"
|
|
454
|
+
]
|
|
455
|
+
};
|
|
456
|
+
var ac = createAccessControl(statement);
|
|
457
|
+
var member = ac.newRole({
|
|
458
|
+
snapshot: [
|
|
459
|
+
"create",
|
|
460
|
+
"read",
|
|
461
|
+
"restore"
|
|
462
|
+
],
|
|
463
|
+
analytics: [
|
|
464
|
+
"read"
|
|
465
|
+
]
|
|
466
|
+
});
|
|
467
|
+
var admin = ac.newRole({
|
|
468
|
+
snapshot: [
|
|
469
|
+
"create",
|
|
470
|
+
"read",
|
|
471
|
+
"update",
|
|
472
|
+
"delete",
|
|
473
|
+
"restore"
|
|
474
|
+
],
|
|
475
|
+
apiKey: [
|
|
476
|
+
"create",
|
|
477
|
+
"read",
|
|
478
|
+
"revoke"
|
|
479
|
+
],
|
|
480
|
+
member: [
|
|
481
|
+
"invite",
|
|
482
|
+
"update"
|
|
483
|
+
],
|
|
484
|
+
organization: [
|
|
485
|
+
"read",
|
|
486
|
+
"update"
|
|
487
|
+
],
|
|
488
|
+
billing: [
|
|
489
|
+
"read"
|
|
490
|
+
],
|
|
491
|
+
analytics: [
|
|
492
|
+
"read"
|
|
493
|
+
]
|
|
494
|
+
});
|
|
495
|
+
var owner = ac.newRole({
|
|
496
|
+
snapshot: [
|
|
497
|
+
"create",
|
|
498
|
+
"read",
|
|
499
|
+
"update",
|
|
500
|
+
"delete",
|
|
501
|
+
"restore"
|
|
502
|
+
],
|
|
503
|
+
apiKey: [
|
|
504
|
+
"create",
|
|
505
|
+
"read",
|
|
506
|
+
"revoke"
|
|
507
|
+
],
|
|
508
|
+
member: [
|
|
509
|
+
"invite",
|
|
510
|
+
"remove",
|
|
511
|
+
"update"
|
|
512
|
+
],
|
|
513
|
+
organization: [
|
|
514
|
+
"read",
|
|
515
|
+
"update",
|
|
516
|
+
"delete"
|
|
517
|
+
],
|
|
518
|
+
billing: [
|
|
519
|
+
"read",
|
|
520
|
+
"update"
|
|
521
|
+
],
|
|
522
|
+
analytics: [
|
|
523
|
+
"read"
|
|
524
|
+
]
|
|
525
|
+
});
|
|
526
|
+
var config2 = {
|
|
527
|
+
auth: {
|
|
528
|
+
enableSignup: process.env.ENABLE_SIGNUP !== "false"
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
var invitationOnlyPlugin = /* @__PURE__ */ __name(() => ({
|
|
532
|
+
id: "invitationOnlyPlugin",
|
|
533
|
+
hooks: {
|
|
534
|
+
before: [
|
|
535
|
+
{
|
|
536
|
+
matcher: /* @__PURE__ */ __name((context) => context.path?.startsWith("/sign-up/email") ?? false, "matcher"),
|
|
537
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
538
|
+
if (config2.auth.enableSignup) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const { email } = ctx.body;
|
|
542
|
+
const hasInvitation = await getPendingInvitationByEmail(email);
|
|
543
|
+
if (!hasInvitation) {
|
|
544
|
+
throw new APIError("BAD_REQUEST", {
|
|
545
|
+
code: "INVALID_INVITATION",
|
|
546
|
+
message: "No invitation found for this email"
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
]
|
|
552
|
+
},
|
|
553
|
+
$ERROR_CODES: {
|
|
554
|
+
INVALID_INVITATION: "No invitation found for this email"
|
|
555
|
+
}
|
|
556
|
+
}), "invitationOnlyPlugin");
|
|
557
|
+
var { subscriptions, webhookEvents, user } = combinedSchema;
|
|
558
|
+
var stripeClient = null;
|
|
559
|
+
new EntitlementsServiceImpl();
|
|
560
|
+
({
|
|
561
|
+
pro: process.env.STRIPE_PRO_MONTHLY_PRICE_ID,
|
|
562
|
+
team: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
|
|
563
|
+
enterprise: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID
|
|
564
|
+
});
|
|
565
|
+
function getStripeClient() {
|
|
566
|
+
if (stripeClient) {
|
|
567
|
+
return stripeClient;
|
|
568
|
+
}
|
|
569
|
+
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
|
570
|
+
if (!stripeSecretKey) {
|
|
571
|
+
throw new Error("Missing env variable STRIPE_SECRET_KEY");
|
|
572
|
+
}
|
|
573
|
+
stripeClient = new Stripe(stripeSecretKey);
|
|
574
|
+
return stripeClient;
|
|
575
|
+
}
|
|
576
|
+
__name(getStripeClient, "getStripeClient");
|
|
577
|
+
var setSubscriptionSeats = /* @__PURE__ */ __name(async (options) => {
|
|
578
|
+
const stripeClient2 = getStripeClient();
|
|
579
|
+
const subscription = await stripeClient2.subscriptions.retrieve(options.id);
|
|
580
|
+
if (!subscription) {
|
|
581
|
+
throw new Error("Subscription not found.");
|
|
582
|
+
}
|
|
583
|
+
await stripeClient2.subscriptions.update(options.id, {
|
|
584
|
+
items: [
|
|
585
|
+
{
|
|
586
|
+
id: subscription.items.data[0].id,
|
|
587
|
+
quantity: options.seats
|
|
588
|
+
}
|
|
589
|
+
]
|
|
590
|
+
});
|
|
591
|
+
}, "setSubscriptionSeats");
|
|
592
|
+
|
|
593
|
+
// ../../packages/integrations/dist/stripe/lib/customer.js
|
|
594
|
+
createLogger({
|
|
595
|
+
name: "payments",
|
|
596
|
+
level: LogLevel.INFO
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ../../packages/integrations/dist/stripe/lib/helper.js
|
|
600
|
+
config.payments.plans;
|
|
601
|
+
|
|
602
|
+
// ../../packages/auth/dist/lib/organization.js
|
|
603
|
+
async function updateSeatsInOrganizationSubscription(organizationId) {
|
|
604
|
+
const organization2 = await getOrganizationWithPurchasesAndMembersCount(organizationId);
|
|
605
|
+
if (!organization2?.purchases || !Array.isArray(organization2.purchases) || organization2.purchases.length === 0) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const activeSubscription = organization2.purchases.find((purchase) => purchase.type === "SUBSCRIPTION");
|
|
609
|
+
if (!activeSubscription?.subscriptionId) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
await setSubscriptionSeats({
|
|
614
|
+
id: activeSubscription.subscriptionId,
|
|
615
|
+
seats: organization2.membersCount
|
|
616
|
+
});
|
|
617
|
+
} catch (error) {
|
|
618
|
+
logger.error("Could not update seats in organization subscription", {
|
|
619
|
+
organizationId,
|
|
620
|
+
error
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
__name(updateSeatsInOrganizationSubscription, "updateSeatsInOrganizationSubscription");
|
|
625
|
+
|
|
626
|
+
// ../../packages/auth/dist/auth.js
|
|
627
|
+
var appUrl = process.env.APP_URL || getBaseUrl();
|
|
628
|
+
var authBaseUrl = process.env.BETTER_AUTH_URL || process.env.BETTER_AUTH_BASE_URL || process.env.APP_URL || `http://localhost:${process.env.PORT || 3e3}`;
|
|
629
|
+
var isLocalDev = (process.env.BETTER_AUTH_URL || "").includes("localhost");
|
|
630
|
+
var isDevelopment = process.env.NODE_ENV !== "production" || isLocalDev;
|
|
631
|
+
var trustedOrigins = isDevelopment ? [
|
|
632
|
+
appUrl,
|
|
633
|
+
authBaseUrl,
|
|
634
|
+
"http://localhost:3000",
|
|
635
|
+
"http://localhost:3001",
|
|
636
|
+
"http://localhost:3002",
|
|
637
|
+
"http://localhost:3003"
|
|
638
|
+
] : [
|
|
639
|
+
appUrl,
|
|
640
|
+
authBaseUrl
|
|
641
|
+
].filter((url, index, arr) => arr.indexOf(url) === index);
|
|
642
|
+
var g = global;
|
|
643
|
+
if (!g.__SNAPBACK_AUTH_REDIS__) {
|
|
644
|
+
g.__SNAPBACK_AUTH_REDIS__ = {
|
|
645
|
+
client: null,
|
|
646
|
+
available: false,
|
|
647
|
+
reconnectAttempts: 0
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function getRedisClient() {
|
|
651
|
+
return g.__SNAPBACK_AUTH_REDIS__.client;
|
|
652
|
+
}
|
|
653
|
+
__name(getRedisClient, "getRedisClient");
|
|
654
|
+
function isRedisAvailable() {
|
|
655
|
+
return g.__SNAPBACK_AUTH_REDIS__.available;
|
|
656
|
+
}
|
|
657
|
+
__name(isRedisAvailable, "isRedisAvailable");
|
|
658
|
+
var redisClient = g.__SNAPBACK_AUTH_REDIS__.client;
|
|
659
|
+
var redisAvailable = g.__SNAPBACK_AUTH_REDIS__.available;
|
|
660
|
+
var authRedisReconnectAttempts = g.__SNAPBACK_AUTH_REDIS__.reconnectAttempts;
|
|
661
|
+
async function initializeRedis() {
|
|
662
|
+
if (g.__SNAPBACK_AUTH_REDIS__.client) {
|
|
663
|
+
redisClient = g.__SNAPBACK_AUTH_REDIS__.client;
|
|
664
|
+
redisAvailable = g.__SNAPBACK_AUTH_REDIS__.available;
|
|
665
|
+
authRedisReconnectAttempts = g.__SNAPBACK_AUTH_REDIS__.reconnectAttempts;
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (!process.env.REDIS_URL) {
|
|
669
|
+
if (process.env.MCP_QUIET !== "1") {
|
|
670
|
+
logger.warn("REDIS_URL not configured - rate limiting will use database fallback");
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
const redis = await import('redis').catch(() => null);
|
|
676
|
+
if (!redis) {
|
|
677
|
+
logger.warn("Redis module not available");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const redisUrl = process.env.REDIS_URL;
|
|
681
|
+
if (!redisUrl.startsWith("redis://") && !redisUrl.startsWith("rediss://")) {
|
|
682
|
+
logger.error("Invalid REDIS_URL format", {
|
|
683
|
+
error: "REDIS_URL must start with 'redis://' or 'rediss://' protocol",
|
|
684
|
+
example: "redis://localhost:6379",
|
|
685
|
+
provided: `${redisUrl.split(":")[0]}:...`
|
|
686
|
+
});
|
|
687
|
+
g.__SNAPBACK_AUTH_REDIS__.available = false;
|
|
688
|
+
redisAvailable = false;
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const client = redis.createClient({
|
|
692
|
+
url: redisUrl,
|
|
693
|
+
socket: {
|
|
694
|
+
// Connection timeout - how long to wait for initial connection
|
|
695
|
+
connectTimeout: 1e4,
|
|
696
|
+
// TCP keepalive - prevents silent connection drops
|
|
697
|
+
keepAlive: 5e3,
|
|
698
|
+
// Reconnection strategy with exponential backoff + jitter
|
|
699
|
+
reconnectStrategy: /* @__PURE__ */ __name((retries, cause) => {
|
|
700
|
+
if (cause?.name === "SocketTimeoutError" || cause?.message?.includes("socket timeout")) {
|
|
701
|
+
logger.warn("Redis socket timeout for Better Auth - not reconnecting", {
|
|
702
|
+
cause: cause?.message
|
|
703
|
+
});
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
g.__SNAPBACK_AUTH_REDIS__.reconnectAttempts++;
|
|
707
|
+
authRedisReconnectAttempts++;
|
|
708
|
+
if (retries > 20) {
|
|
709
|
+
logger.error("Redis max retries exceeded", {
|
|
710
|
+
retries,
|
|
711
|
+
cause: cause?.message
|
|
712
|
+
});
|
|
713
|
+
return new Error("Max retries reached");
|
|
714
|
+
}
|
|
715
|
+
const baseDelay = Math.min(2 ** retries * 100, 3e4);
|
|
716
|
+
const jitter = Math.floor(Math.random() * 200);
|
|
717
|
+
const delay = baseDelay + jitter;
|
|
718
|
+
if (process.env.MCP_QUIET !== "1" && retries % 5 === 0) {
|
|
719
|
+
logger.debug("Redis reconnecting for Better Auth", {
|
|
720
|
+
retries,
|
|
721
|
+
delay
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
return delay;
|
|
725
|
+
}, "reconnectStrategy")
|
|
726
|
+
},
|
|
727
|
+
// Application-level ping to keep connection alive
|
|
728
|
+
pingInterval: 6e4
|
|
729
|
+
});
|
|
730
|
+
client.on("error", (err) => {
|
|
731
|
+
if (err.message.includes("ECONNRESET") || err.message.includes("ECONNREFUSED")) {
|
|
732
|
+
logger.debug("Redis connection error (will reconnect)", {
|
|
733
|
+
error: err.message
|
|
734
|
+
});
|
|
735
|
+
} else {
|
|
736
|
+
logger.warn("Redis connection error", {
|
|
737
|
+
error: err.message
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
g.__SNAPBACK_AUTH_REDIS__.available = false;
|
|
741
|
+
redisAvailable = false;
|
|
742
|
+
});
|
|
743
|
+
client.on("connect", () => {
|
|
744
|
+
if (process.env.MCP_QUIET !== "1" && !g.__SNAPBACK_REDIS_CONNECT_LOGGED__) {
|
|
745
|
+
g.__SNAPBACK_REDIS_CONNECT_LOGGED__ = true;
|
|
746
|
+
logger.info("Redis connected for Better Auth secondary storage");
|
|
747
|
+
}
|
|
748
|
+
g.__SNAPBACK_AUTH_REDIS__.available = true;
|
|
749
|
+
redisAvailable = true;
|
|
750
|
+
});
|
|
751
|
+
client.on("ready", () => {
|
|
752
|
+
g.__SNAPBACK_AUTH_REDIS__.reconnectAttempts = 0;
|
|
753
|
+
authRedisReconnectAttempts = 0;
|
|
754
|
+
g.__SNAPBACK_AUTH_REDIS__.available = true;
|
|
755
|
+
redisAvailable = true;
|
|
756
|
+
});
|
|
757
|
+
client.on("reconnecting", () => {
|
|
758
|
+
if (process.env.MCP_QUIET !== "1") {
|
|
759
|
+
logger.debug("Redis reconnecting for Better Auth...");
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
await client.connect();
|
|
763
|
+
g.__SNAPBACK_AUTH_REDIS__.client = client;
|
|
764
|
+
g.__SNAPBACK_AUTH_REDIS__.available = true;
|
|
765
|
+
redisClient = client;
|
|
766
|
+
redisAvailable = true;
|
|
767
|
+
if (process.env.MCP_QUIET !== "1" && !g.__SNAPBACK_REDIS_INIT_LOGGED__) {
|
|
768
|
+
g.__SNAPBACK_REDIS_INIT_LOGGED__ = true;
|
|
769
|
+
logger.info("\u2705 Better Auth Redis secondary storage initialized with production config");
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
logger.warn("Redis initialization failed - using database fallback", {
|
|
773
|
+
error: error instanceof Error ? error.message : String(error)
|
|
774
|
+
});
|
|
775
|
+
g.__SNAPBACK_AUTH_REDIS__.available = false;
|
|
776
|
+
redisAvailable = false;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
__name(initializeRedis, "initializeRedis");
|
|
780
|
+
function getAuthRedisReconnectAttempts() {
|
|
781
|
+
return authRedisReconnectAttempts;
|
|
782
|
+
}
|
|
783
|
+
__name(getAuthRedisReconnectAttempts, "getAuthRedisReconnectAttempts");
|
|
784
|
+
initializeRedis().catch((err) => {
|
|
785
|
+
logger.error("Failed to initialize Redis for Better Auth", {
|
|
786
|
+
error: err instanceof Error ? err.message : String(err)
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
var _auth = betterAuth({
|
|
790
|
+
// ✅ Base URL for callbacks and redirects (required by Better Auth)
|
|
791
|
+
// Uses BETTER_AUTH_URL env var, falling back to localhost with PORT
|
|
792
|
+
baseURL: authBaseUrl,
|
|
793
|
+
// Extend the user schema with additional fields
|
|
794
|
+
schema: {
|
|
795
|
+
user: {
|
|
796
|
+
fields: {
|
|
797
|
+
onboardingComplete: {
|
|
798
|
+
type: "boolean",
|
|
799
|
+
required: false,
|
|
800
|
+
defaultValue: false
|
|
801
|
+
},
|
|
802
|
+
deviceFingerprint: {
|
|
803
|
+
type: "string",
|
|
804
|
+
required: false
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
appName: "SnapBack",
|
|
810
|
+
// ✅ SECURITY: Prevent account enumeration attacks
|
|
811
|
+
// OWASP ASVS 2.2.1 - Don't reveal whether username exists
|
|
812
|
+
disablePaths: [
|
|
813
|
+
"/is-username-available"
|
|
814
|
+
],
|
|
815
|
+
endpoints: {
|
|
816
|
+
GET: {
|
|
817
|
+
"/health": {
|
|
818
|
+
async handler() {
|
|
819
|
+
return new Response("OK", {
|
|
820
|
+
status: 200
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
emailAndPassword: {
|
|
827
|
+
enabled: true
|
|
828
|
+
},
|
|
829
|
+
socialProviders: {
|
|
830
|
+
// Only include social providers if credentials are configured
|
|
831
|
+
// This prevents Better Auth from logging warnings that corrupt MCP stdio
|
|
832
|
+
// Note: Access process.env directly for Vercel compatibility (t3-env wrapper issue)
|
|
833
|
+
...process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET ? {
|
|
834
|
+
github: {
|
|
835
|
+
clientId: process.env.GITHUB_CLIENT_ID,
|
|
836
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET
|
|
837
|
+
}
|
|
838
|
+
} : {},
|
|
839
|
+
...process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET ? {
|
|
840
|
+
google: {
|
|
841
|
+
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
842
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET
|
|
843
|
+
}
|
|
844
|
+
} : {}
|
|
845
|
+
},
|
|
846
|
+
database: drizzleAdapter(db, {
|
|
847
|
+
provider: "pg",
|
|
848
|
+
schema: combinedSchema
|
|
849
|
+
}),
|
|
850
|
+
session: {
|
|
851
|
+
expiresIn: 60 * 60 * 24 * 7,
|
|
852
|
+
updateAge: 60 * 60 * 24,
|
|
853
|
+
// ✅ CRITICAL FIX: Always store sessions in DB so findSession() falls back
|
|
854
|
+
// to the primary DB when Redis (secondaryStorage) is unavailable or misses.
|
|
855
|
+
// Without this, a Redis miss in serverless (Vercel cold start, connection lag)
|
|
856
|
+
// causes findSession to return null WITHOUT checking the DB, silently breaking
|
|
857
|
+
// bearer token validation (CLI device auth, auto-provision API key).
|
|
858
|
+
storeSessionInDatabase: true,
|
|
859
|
+
// ✅ OPTIMIZATION: Cookie cache for 80% database load reduction
|
|
860
|
+
// Note: When database/secondaryStorage is configured, we use cookieCache
|
|
861
|
+
// WITHOUT refreshCache (which is only for stateless/DB-less setups)
|
|
862
|
+
// The cache reduces DB queries by storing session data in signed cookies
|
|
863
|
+
cookieCache: {
|
|
864
|
+
enabled: true,
|
|
865
|
+
maxAge: 5 * 60,
|
|
866
|
+
strategy: "jwe"
|
|
867
|
+
}
|
|
868
|
+
},
|
|
869
|
+
account: {
|
|
870
|
+
accountLinking: {
|
|
871
|
+
enabled: true,
|
|
872
|
+
trustedProviders: [
|
|
873
|
+
"google",
|
|
874
|
+
"github"
|
|
875
|
+
]
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
trustedOrigins,
|
|
879
|
+
// ✅ OPTIMIZATION: Redis secondary storage for distributed rate limiting
|
|
880
|
+
// Uses a lazy wrapper so Redis connects asynchronously without blocking
|
|
881
|
+
// betterAuth() initialization. Each call checks redisAvailable at runtime.
|
|
882
|
+
secondaryStorage: {
|
|
883
|
+
get: /* @__PURE__ */ __name(async (key) => {
|
|
884
|
+
if (!redisAvailable || !redisClient) return null;
|
|
885
|
+
try {
|
|
886
|
+
return await redisClient.get(key);
|
|
887
|
+
} catch (error) {
|
|
888
|
+
logger.error("Redis get failed", {
|
|
889
|
+
key,
|
|
890
|
+
error
|
|
891
|
+
});
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
}, "get"),
|
|
895
|
+
set: /* @__PURE__ */ __name(async (key, value, ttl) => {
|
|
896
|
+
if (!redisAvailable || !redisClient) return;
|
|
897
|
+
try {
|
|
898
|
+
if (ttl) {
|
|
899
|
+
await redisClient.set(key, value, {
|
|
900
|
+
EX: ttl
|
|
901
|
+
});
|
|
902
|
+
} else {
|
|
903
|
+
await redisClient.set(key, value);
|
|
904
|
+
}
|
|
905
|
+
} catch (error) {
|
|
906
|
+
logger.error("Redis set failed", {
|
|
907
|
+
key,
|
|
908
|
+
error
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}, "set"),
|
|
912
|
+
delete: /* @__PURE__ */ __name(async (key) => {
|
|
913
|
+
if (!redisAvailable || !redisClient) return;
|
|
914
|
+
try {
|
|
915
|
+
await redisClient.del(key);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
logger.error("Redis delete failed", {
|
|
918
|
+
key,
|
|
919
|
+
error
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
}, "delete")
|
|
923
|
+
},
|
|
924
|
+
advanced: {
|
|
925
|
+
// ✅ FIX: Use isDevelopment (not NODE_ENV) so Doppler prd config running locally
|
|
926
|
+
// still gets dev-friendly cookie settings. isDevelopment checks BETTER_AUTH_URL
|
|
927
|
+
// for localhost, which Doppler overrides correctly.
|
|
928
|
+
useSecureCookies: !isDevelopment,
|
|
929
|
+
crossSiteRequestForgery: {
|
|
930
|
+
enabled: true,
|
|
931
|
+
// Verify origin header matches trusted origins
|
|
932
|
+
checkOrigin: true
|
|
933
|
+
},
|
|
934
|
+
// ✅ OPTIMIZATION: Explicit ID generation using nanoid
|
|
935
|
+
database: {
|
|
936
|
+
generateId: /* @__PURE__ */ __name(() => nanoid(), "generateId"),
|
|
937
|
+
defaultFindManyLimit: 100,
|
|
938
|
+
experimentalJoins: false
|
|
939
|
+
},
|
|
940
|
+
// ✅ OPTIMIZATION: IP tracking configuration for security audit
|
|
941
|
+
ipAddress: {
|
|
942
|
+
ipAddressHeaders: [
|
|
943
|
+
"cf-connecting-ip",
|
|
944
|
+
"x-real-ip",
|
|
945
|
+
"x-forwarded-for",
|
|
946
|
+
"x-client-ip"
|
|
947
|
+
],
|
|
948
|
+
disableIpTracking: false
|
|
949
|
+
},
|
|
950
|
+
// ✅ OPTIMIZATION: Enhanced cookie configuration
|
|
951
|
+
// ✅ FIX: Use isDevelopment consistently (not env.NODE_ENV === "production")
|
|
952
|
+
// Doppler prd sets NODE_ENV=production even when running locally, which would
|
|
953
|
+
// incorrectly set domain=".snapback.dev" on localhost cookies.
|
|
954
|
+
crossSubDomainCookies: {
|
|
955
|
+
enabled: !isDevelopment,
|
|
956
|
+
domain: !isDevelopment ? ".snapback.dev" : void 0
|
|
957
|
+
},
|
|
958
|
+
defaultCookieAttributes: {
|
|
959
|
+
sameSite: "lax",
|
|
960
|
+
secure: !isDevelopment,
|
|
961
|
+
httpOnly: true,
|
|
962
|
+
path: "/"
|
|
963
|
+
},
|
|
964
|
+
// ✅ FIX: OAuth state/pkce cookies need SameSite=None for cross-site redirects
|
|
965
|
+
// See: https://github.com/better-auth/better-auth/issues/6483
|
|
966
|
+
// Note: In development (localhost), browsers allow SameSite=None without Secure
|
|
967
|
+
cookies: {
|
|
968
|
+
state: {
|
|
969
|
+
name: "snapback.state",
|
|
970
|
+
attributes: {
|
|
971
|
+
sameSite: isDevelopment ? "lax" : "none",
|
|
972
|
+
secure: !isDevelopment,
|
|
973
|
+
httpOnly: true,
|
|
974
|
+
path: "/",
|
|
975
|
+
maxAge: 600
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
pkce: {
|
|
979
|
+
name: "snapback.pkce",
|
|
980
|
+
attributes: {
|
|
981
|
+
sameSite: isDevelopment ? "lax" : "none",
|
|
982
|
+
secure: !isDevelopment,
|
|
983
|
+
httpOnly: true,
|
|
984
|
+
path: "/",
|
|
985
|
+
maxAge: 600
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
},
|
|
989
|
+
cookiePrefix: "snapback"
|
|
990
|
+
},
|
|
991
|
+
// Rate limiting configuration (replaces 340+ lines of custom rate limit code)
|
|
992
|
+
rateLimit: {
|
|
993
|
+
window: 60,
|
|
994
|
+
max: 100,
|
|
995
|
+
// ✅ OPTIMIZATION: Use Redis for distributed rate limiting via secondaryStorage.
|
|
996
|
+
// secondaryStorage lazy-checks redisAvailable at runtime, so this is always safe.
|
|
997
|
+
storage: "secondary-storage",
|
|
998
|
+
customRules: {
|
|
999
|
+
// Strict limits for authentication endpoints
|
|
1000
|
+
"/sign-in/email": {
|
|
1001
|
+
window: 10,
|
|
1002
|
+
max: 3
|
|
1003
|
+
},
|
|
1004
|
+
"/sign-in/social": {
|
|
1005
|
+
window: 10,
|
|
1006
|
+
max: 5
|
|
1007
|
+
},
|
|
1008
|
+
"/sign-up": {
|
|
1009
|
+
window: 60,
|
|
1010
|
+
max: 5
|
|
1011
|
+
},
|
|
1012
|
+
"/password-reset": {
|
|
1013
|
+
window: 60,
|
|
1014
|
+
max: 3
|
|
1015
|
+
},
|
|
1016
|
+
// RFC 8628 Device Authorization - protect against brute-force of user codes
|
|
1017
|
+
"/device/*": {
|
|
1018
|
+
window: 60,
|
|
1019
|
+
max: isDevelopment ? 100 : 10
|
|
1020
|
+
},
|
|
1021
|
+
// Higher limits for normal API endpoints
|
|
1022
|
+
"/api/*": {
|
|
1023
|
+
window: 60,
|
|
1024
|
+
max: 500
|
|
1025
|
+
},
|
|
1026
|
+
// No rate limiting for health checks
|
|
1027
|
+
"/health": false,
|
|
1028
|
+
"/health/ready": false,
|
|
1029
|
+
"/health/live": false
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1032
|
+
// Use database hooks for audit logging (replaces 371 lines of custom auth-audit.ts)
|
|
1033
|
+
// Also includes rate limiting configuration (replaces 340+ lines of custom rate limit code)
|
|
1034
|
+
databaseHooks: {
|
|
1035
|
+
session: {
|
|
1036
|
+
create: {
|
|
1037
|
+
after: /* @__PURE__ */ __name(async (session) => {
|
|
1038
|
+
await trackEvent("session.created", {
|
|
1039
|
+
userId: session.userId
|
|
1040
|
+
});
|
|
1041
|
+
await trackEvent("auth.signin", {
|
|
1042
|
+
userId: session.userId,
|
|
1043
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1044
|
+
});
|
|
1045
|
+
try {
|
|
1046
|
+
const { db: db2 } = await import('./dist-YZBJAYEJ.js');
|
|
1047
|
+
const { sql } = await import('drizzle-orm');
|
|
1048
|
+
if (db2) {
|
|
1049
|
+
const result = await db2.execute(sql`
|
|
1050
|
+
DELETE FROM session
|
|
1051
|
+
WHERE "userId" = ${session.userId}
|
|
1052
|
+
AND id != ${session.id}
|
|
1053
|
+
`);
|
|
1054
|
+
const rotatedCount = result.rowCount || 0;
|
|
1055
|
+
if (rotatedCount > 0) {
|
|
1056
|
+
logger.info("Session regenerated on login - old sessions invalidated", {
|
|
1057
|
+
userId: session.userId,
|
|
1058
|
+
sessionId: session.id,
|
|
1059
|
+
rotatedCount
|
|
1060
|
+
});
|
|
1061
|
+
await trackEvent("session.regenerated", {
|
|
1062
|
+
userId: session.userId,
|
|
1063
|
+
reason: "login",
|
|
1064
|
+
rotatedCount
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
logger.warn("Session regeneration failed on login", {
|
|
1070
|
+
userId: session.userId,
|
|
1071
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
if (!session.activeOrganizationId) {
|
|
1076
|
+
const { db: db2, combinedSchema: combinedSchema2 } = await import('./dist-YZBJAYEJ.js');
|
|
1077
|
+
const { sql } = await import('drizzle-orm');
|
|
1078
|
+
if (db2) {
|
|
1079
|
+
const { member: member2, session: sessionTable } = combinedSchema2;
|
|
1080
|
+
const membership = await db2.select({
|
|
1081
|
+
organizationId: member2.organizationId
|
|
1082
|
+
}).from(member2).where(sql`${member2.userId} = ${session.userId}`).orderBy(member2.createdAt).limit(1);
|
|
1083
|
+
if (membership.length > 0 && membership[0]?.organizationId) {
|
|
1084
|
+
await db2.update(sessionTable).set({
|
|
1085
|
+
activeOrganizationId: membership[0].organizationId
|
|
1086
|
+
}).where(sql`${sessionTable.id} = ${session.id}`);
|
|
1087
|
+
logger.info("Auto-set active organization for session", {
|
|
1088
|
+
userId: session.userId,
|
|
1089
|
+
sessionId: session.id,
|
|
1090
|
+
organizationId: membership[0].organizationId
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
logger.warn("Failed to auto-set active organization", {
|
|
1097
|
+
userId: session.userId,
|
|
1098
|
+
sessionId: session.id,
|
|
1099
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}, "after")
|
|
1103
|
+
},
|
|
1104
|
+
delete: {
|
|
1105
|
+
after: /* @__PURE__ */ __name(async (session) => {
|
|
1106
|
+
await trackEvent("session.revoked", {
|
|
1107
|
+
userId: session.userId
|
|
1108
|
+
});
|
|
1109
|
+
await trackEvent("auth.signout", {
|
|
1110
|
+
userId: session.userId,
|
|
1111
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1112
|
+
});
|
|
1113
|
+
}, "after")
|
|
1114
|
+
}
|
|
1115
|
+
},
|
|
1116
|
+
user: {
|
|
1117
|
+
create: {
|
|
1118
|
+
after: /* @__PURE__ */ __name(async (user2) => {
|
|
1119
|
+
await trackEvent("auth.signup", {
|
|
1120
|
+
userId: user2.id,
|
|
1121
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1122
|
+
});
|
|
1123
|
+
try {
|
|
1124
|
+
const { onOAuthSuccess } = await import('./pioneer-oauth-hook-V2JKEXM7.js');
|
|
1125
|
+
await onOAuthSuccess(user2);
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
logger.error("Pioneer OAuth hook failed", {
|
|
1128
|
+
userId: user2.id,
|
|
1129
|
+
error
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
try {
|
|
1133
|
+
const { autoProvisionOrganization } = await import('./auto-provision-organization-SF6XM7X4.js');
|
|
1134
|
+
const result = await autoProvisionOrganization(user2);
|
|
1135
|
+
if (!result.success) {
|
|
1136
|
+
logger.warn("Auto-org provisioning failed", {
|
|
1137
|
+
userId: user2.id,
|
|
1138
|
+
error: result.error
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
logger.error("Auto-org provisioning error", {
|
|
1143
|
+
userId: user2.id,
|
|
1144
|
+
error
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}, "after")
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
plugins: [
|
|
1152
|
+
// ✅ CRITICAL: Bearer plugin enables getSession to validate Bearer tokens
|
|
1153
|
+
// Required for CLI device flow where access_token is passed via Authorization header
|
|
1154
|
+
// Without this, getSession only checks session cookies, not Bearer tokens
|
|
1155
|
+
bearer(),
|
|
1156
|
+
// ✅ ZERO-CONFIG: Anonymous plugin enables unauthenticated MCP usage (free tier)
|
|
1157
|
+
// Users get a real anonymous session on first install — no credentials required.
|
|
1158
|
+
// When user runs `snap login`, Better Auth links the anonymous account to the real
|
|
1159
|
+
// user via onLinkAccount, migrating any session history automatically.
|
|
1160
|
+
// Requires: isAnonymous boolean column on users table (see migration note below)
|
|
1161
|
+
anonymous({
|
|
1162
|
+
onLinkAccount: /* @__PURE__ */ __name(async ({ anonymousUser, newUser }) => {
|
|
1163
|
+
logger.info("[Auth] Anonymous account linked", {
|
|
1164
|
+
anonymousUserId: anonymousUser.user.id,
|
|
1165
|
+
newUserId: newUser.user.id
|
|
1166
|
+
});
|
|
1167
|
+
}, "onLinkAccount")
|
|
1168
|
+
}),
|
|
1169
|
+
// ✅ RFC 8628 Device Authorization Grant Flow (for WSL, Remote SSH, Codespaces)
|
|
1170
|
+
deviceAuthorization({
|
|
1171
|
+
// IMPORTANT: Must point to /link page on web app where users verify their device code
|
|
1172
|
+
// Better Auth uses baseURL to construct full URI, but baseURL might point to API
|
|
1173
|
+
// Users need to visit the web app to verify their device code
|
|
1174
|
+
verificationUri: `${appUrl}/link`
|
|
1175
|
+
}),
|
|
1176
|
+
admin$1(),
|
|
1177
|
+
apiKey({
|
|
1178
|
+
// ✅ CONSOLIDATED: Single source of truth for API key management
|
|
1179
|
+
// Replaces: KeysDb, apikeys-service, in-memory keys.ts
|
|
1180
|
+
// Key format: sk_live_[64 chars] or sk_test_[64 chars]
|
|
1181
|
+
defaultPrefix: "sk_live_",
|
|
1182
|
+
defaultKeyLength: 64,
|
|
1183
|
+
// Schema mapping - use our existing apiKeys table with snake_case columns
|
|
1184
|
+
schema: {
|
|
1185
|
+
apikey: {
|
|
1186
|
+
modelName: "apiKeys",
|
|
1187
|
+
fields: {
|
|
1188
|
+
// Map Better Auth field names to our Drizzle schema property names
|
|
1189
|
+
userId: "userId",
|
|
1190
|
+
name: "name",
|
|
1191
|
+
key: "key",
|
|
1192
|
+
start: "start",
|
|
1193
|
+
prefix: "prefix",
|
|
1194
|
+
createdAt: "createdAt",
|
|
1195
|
+
updatedAt: "updatedAt"
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
},
|
|
1199
|
+
// Permissions model: { resource: [actions] }
|
|
1200
|
+
permissions: {
|
|
1201
|
+
defaultPermissions: {
|
|
1202
|
+
"snapback:analyze": [
|
|
1203
|
+
"read"
|
|
1204
|
+
],
|
|
1205
|
+
"snapback:snapshot": [
|
|
1206
|
+
"read",
|
|
1207
|
+
"write"
|
|
1208
|
+
],
|
|
1209
|
+
"snapback:context": [
|
|
1210
|
+
"read"
|
|
1211
|
+
],
|
|
1212
|
+
// MCP server tool access - enables API key usage with MCP protocol
|
|
1213
|
+
mcp: [
|
|
1214
|
+
"tools"
|
|
1215
|
+
],
|
|
1216
|
+
// API access levels
|
|
1217
|
+
api: [
|
|
1218
|
+
"read",
|
|
1219
|
+
"write"
|
|
1220
|
+
],
|
|
1221
|
+
// CLI operations
|
|
1222
|
+
cli: [
|
|
1223
|
+
"snapshots"
|
|
1224
|
+
]
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
// Enable automatic session creation for API key-authenticated requests
|
|
1228
|
+
// This eliminates need for separate verifyApiKey + getSession calls
|
|
1229
|
+
enableSessionForAPIKeys: true,
|
|
1230
|
+
// Global rate limiting for all API keys
|
|
1231
|
+
rateLimit: {
|
|
1232
|
+
enabled: true,
|
|
1233
|
+
timeWindow: 864e5,
|
|
1234
|
+
maxRequests: 1e4
|
|
1235
|
+
},
|
|
1236
|
+
// Enable metadata storage for additional key info
|
|
1237
|
+
enableMetadata: true
|
|
1238
|
+
}),
|
|
1239
|
+
// ✅ LEVEL 4: JWT plugin with device-specific token issuance
|
|
1240
|
+
jwt({
|
|
1241
|
+
issuer: appUrl,
|
|
1242
|
+
audience: [
|
|
1243
|
+
"vscode",
|
|
1244
|
+
"mcp",
|
|
1245
|
+
"cli"
|
|
1246
|
+
],
|
|
1247
|
+
expirationTime: 60 * 15
|
|
1248
|
+
}),
|
|
1249
|
+
magicLink({
|
|
1250
|
+
// SECURITY: Token expiration (default 5 min per Better Auth)
|
|
1251
|
+
// Links older than this are rejected
|
|
1252
|
+
expiresIn: 300,
|
|
1253
|
+
async sendMagicLink({ email, url }) {
|
|
1254
|
+
await send({
|
|
1255
|
+
to: email,
|
|
1256
|
+
subject: "Sign in to SnapBack",
|
|
1257
|
+
text: `Click the link to sign in: ${url}`,
|
|
1258
|
+
html: `<p>Click the link to sign in: <a href="${url}">${url}</a></p>`
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
}),
|
|
1262
|
+
openAPI(),
|
|
1263
|
+
// ✅ OPTIMIZATION: Organization plugin with RBAC configuration
|
|
1264
|
+
// ✅ SECURITY: Session rotation on privilege escalation
|
|
1265
|
+
// Note: Call rotateSessionsOnOrgRoleChange() manually after updateMemberRole()
|
|
1266
|
+
// See: src/lib/session-rotation.ts
|
|
1267
|
+
organization({
|
|
1268
|
+
// Access control instance from organization-permissions.ts
|
|
1269
|
+
ac,
|
|
1270
|
+
// Define roles and permissions
|
|
1271
|
+
roles: {
|
|
1272
|
+
owner,
|
|
1273
|
+
admin,
|
|
1274
|
+
member
|
|
1275
|
+
},
|
|
1276
|
+
// Organization creation settings
|
|
1277
|
+
allowUserToCreateOrganization: true,
|
|
1278
|
+
organizationLimit: 5,
|
|
1279
|
+
// SECURITY: Invitation expiration (7 days per OWASP recommendation)
|
|
1280
|
+
// After this period, invitations must be re-sent
|
|
1281
|
+
invitationExpiresIn: 7 * 24 * 60 * 60,
|
|
1282
|
+
// Send invitation emails
|
|
1283
|
+
async sendInvitationEmail({ invitation, organization: organization2 }) {
|
|
1284
|
+
const { id, email, expiresAt } = invitation;
|
|
1285
|
+
const url = new URL(appUrl);
|
|
1286
|
+
url.pathname = "/accept-invitation";
|
|
1287
|
+
url.searchParams.set("invitationId", id);
|
|
1288
|
+
url.searchParams.set("email", email);
|
|
1289
|
+
const expiresInDays = Math.ceil((expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60 * 24));
|
|
1290
|
+
await send({
|
|
1291
|
+
to: email,
|
|
1292
|
+
subject: `You've been invited to join ${organization2.name} on SnapBack`,
|
|
1293
|
+
text: `You've been invited to join ${organization2.name}. Click the link to accept: ${url.toString()}
|
|
1294
|
+
|
|
1295
|
+
This invitation expires in ${expiresInDays} days.`,
|
|
1296
|
+
html: `<p>You've been invited to join <strong>${organization2.name}</strong> on SnapBack.</p><p><a href="${url.toString()}">Click here to accept the invitation</a></p><p><small>This invitation expires in ${expiresInDays} days.</small></p>`
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}),
|
|
1300
|
+
invitationOnlyPlugin(),
|
|
1301
|
+
// ✅ SECURITY: Password breach detection via HaveIBeenPwned
|
|
1302
|
+
// OWASP A07:2021, NIST SP 800-63B Section 5.1.1.2
|
|
1303
|
+
// Automatically checks passwords during signup and password changes
|
|
1304
|
+
haveIBeenPwned(),
|
|
1305
|
+
// ✅ SECURITY: Two-Factor Authentication
|
|
1306
|
+
// NIST SP 800-63B compliant configuration
|
|
1307
|
+
twoFactor({
|
|
1308
|
+
// Application name shown in authenticator apps
|
|
1309
|
+
issuer: "SnapBack",
|
|
1310
|
+
// Require verification on enable for security
|
|
1311
|
+
skipVerificationOnEnable: false,
|
|
1312
|
+
// TOTP configuration
|
|
1313
|
+
totpOptions: {
|
|
1314
|
+
// 6-digit codes (standard)
|
|
1315
|
+
digits: 6,
|
|
1316
|
+
// 30-second validity period (TOTP standard)
|
|
1317
|
+
period: 30
|
|
1318
|
+
},
|
|
1319
|
+
// Backup codes configuration (enhanced when ENABLE_ENHANCED_2FA=true)
|
|
1320
|
+
backupCodeOptions: {
|
|
1321
|
+
// Number of backup codes to generate
|
|
1322
|
+
amount: ENABLE_ENHANCED_2FA ? 10 : 6,
|
|
1323
|
+
// Backup code length (longer for enterprise)
|
|
1324
|
+
length: ENABLE_ENHANCED_2FA ? 12 : 8
|
|
1325
|
+
}
|
|
1326
|
+
}),
|
|
1327
|
+
username({}),
|
|
1328
|
+
passkey({}),
|
|
1329
|
+
// =============================================================================
|
|
1330
|
+
// Enterprise Auth Plugins (Feature Flag Gated)
|
|
1331
|
+
// =============================================================================
|
|
1332
|
+
// ✅ ENTERPRISE: SSO Plugin (SAML 2.0 / OIDC)
|
|
1333
|
+
// Requires: ENABLE_SSO=true, @better-auth/sso package
|
|
1334
|
+
...ENABLE_SSO ? [
|
|
1335
|
+
sso({
|
|
1336
|
+
// Auto-provision users to organization based on domain
|
|
1337
|
+
provisionUser: /* @__PURE__ */ __name(async ({ user: user2, userInfo }) => {
|
|
1338
|
+
const emailHash = user2.email ? `email_${Math.abs(user2.email.split("").reduce((acc, char) => (acc << 5) - acc + char.charCodeAt(0) | 0, 0)).toString(16).padStart(8, "0")}` : "none";
|
|
1339
|
+
logger.info("SSO user provisioning", {
|
|
1340
|
+
userId: user2.id,
|
|
1341
|
+
emailHash
|
|
1342
|
+
});
|
|
1343
|
+
if (userInfo?.email_verified && !user2.emailVerified) ;
|
|
1344
|
+
}, "provisionUser"),
|
|
1345
|
+
// Organization auto-provisioning settings
|
|
1346
|
+
organizationProvisioning: {
|
|
1347
|
+
defaultRole: "member"
|
|
1348
|
+
},
|
|
1349
|
+
// Allow new users via SSO (don't require pre-existing account)
|
|
1350
|
+
disableImplicitSignUp: false,
|
|
1351
|
+
// Trust IdP email verification status
|
|
1352
|
+
trustEmailVerified: true,
|
|
1353
|
+
// Domain verification for SSO providers
|
|
1354
|
+
domainVerification: {
|
|
1355
|
+
enabled: true
|
|
1356
|
+
}
|
|
1357
|
+
})
|
|
1358
|
+
] : [],
|
|
1359
|
+
// ✅ SECURITY: Captcha Plugin (Bot Protection)
|
|
1360
|
+
// OWASP A07:2021 - Prevents automated attacks on auth endpoints
|
|
1361
|
+
// Requires: ENABLE_CAPTCHA=true, CAPTCHA_SECRET_KEY env var
|
|
1362
|
+
...ENABLE_CAPTCHA && process.env.CAPTCHA_SECRET_KEY ? [
|
|
1363
|
+
captcha({
|
|
1364
|
+
provider: "cloudflare-turnstile",
|
|
1365
|
+
secretKey: process.env.CAPTCHA_SECRET_KEY,
|
|
1366
|
+
// Protect critical auth endpoints
|
|
1367
|
+
endpoints: [
|
|
1368
|
+
"/sign-up/email",
|
|
1369
|
+
"/sign-in/email",
|
|
1370
|
+
"/forget-password",
|
|
1371
|
+
"/password-reset"
|
|
1372
|
+
]
|
|
1373
|
+
})
|
|
1374
|
+
] : [],
|
|
1375
|
+
// ✅ ENTERPRISE: Multi-Session Plugin (Device Management)
|
|
1376
|
+
// Allows users to manage active sessions across devices
|
|
1377
|
+
// Requires: ENABLE_MULTI_SESSION=true
|
|
1378
|
+
...ENABLE_MULTI_SESSION ? [
|
|
1379
|
+
multiSession({
|
|
1380
|
+
// Enterprise limit: 10 concurrent sessions
|
|
1381
|
+
maximumSessions: 10
|
|
1382
|
+
})
|
|
1383
|
+
] : []
|
|
1384
|
+
],
|
|
1385
|
+
onAPIError: {
|
|
1386
|
+
onError(error, ctx) {
|
|
1387
|
+
const errorDetails = {
|
|
1388
|
+
error,
|
|
1389
|
+
context: ctx
|
|
1390
|
+
};
|
|
1391
|
+
if (error && typeof error === "object") {
|
|
1392
|
+
if ("code" in error) {
|
|
1393
|
+
errorDetails.errorCode = error.code;
|
|
1394
|
+
}
|
|
1395
|
+
if ("message" in error) {
|
|
1396
|
+
errorDetails.errorMessage = error.message;
|
|
1397
|
+
}
|
|
1398
|
+
if ("statusCode" in error) {
|
|
1399
|
+
errorDetails.statusCode = error.statusCode;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
let isOAuthError = false;
|
|
1403
|
+
if (ctx && typeof ctx === "object") {
|
|
1404
|
+
if ("request" in ctx) {
|
|
1405
|
+
const request = ctx.request;
|
|
1406
|
+
errorDetails.requestUrl = request.url;
|
|
1407
|
+
errorDetails.requestMethod = request.method;
|
|
1408
|
+
if (request.url?.includes("/api/auth/callback/")) {
|
|
1409
|
+
const provider = request.url.split("/callback/")[1]?.split("?")[0];
|
|
1410
|
+
errorDetails.oauthProvider = provider;
|
|
1411
|
+
errorDetails.errorType = "OAuth Callback Error";
|
|
1412
|
+
isOAuthError = true;
|
|
1413
|
+
logger.error("OAuth Callback Error", errorDetails);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
if (!isOAuthError) {
|
|
1418
|
+
logger.error("Better Auth API Error", errorDetails);
|
|
1419
|
+
}
|
|
1420
|
+
trackEvent("auth.signin_failed", {
|
|
1421
|
+
errorCode: errorDetails.errorCode,
|
|
1422
|
+
errorMessage: errorDetails.errorMessage,
|
|
1423
|
+
path: errorDetails.requestUrl,
|
|
1424
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1425
|
+
}).catch(() => {
|
|
1426
|
+
});
|
|
1427
|
+
import('./dist-E7E2T3DQ.js').then(({ captureError }) => {
|
|
1428
|
+
if (captureError && error instanceof Error) {
|
|
1429
|
+
captureError(error, {
|
|
1430
|
+
tags: {
|
|
1431
|
+
errorType: isOAuthError ? "oauth_callback" : "auth_api",
|
|
1432
|
+
...errorDetails.errorCode ? {
|
|
1433
|
+
errorCode: String(errorDetails.errorCode)
|
|
1434
|
+
} : {}
|
|
1435
|
+
},
|
|
1436
|
+
extra: errorDetails
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
}).catch(() => {
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
var auth = _auth;
|
|
1445
|
+
|
|
1446
|
+
export { auth, getAuthRedisReconnectAttempts, getRedisClient, isRedisAvailable, redisAvailable, redisClient, updateSeatsInOrganizationSubscription };
|