fraim 2.0.297 → 2.0.298
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/core/handoff-contracts.js +21 -0
- package/dist/src/fraim/db-service.js +64 -0
- package/dist/src/local-mcp-server/stdio-server.js +36 -1
- package/dist/src/middleware/auth.js +1 -1
- package/dist/src/routes/auth-routes.js +39 -0
- package/dist/src/routes/oauth-routes.js +20 -1
- package/dist/src/services/admin-service.js +8 -3
- package/package.json +1 -1
|
@@ -152,18 +152,39 @@ function validateNextJobRecommendations(value) {
|
|
|
152
152
|
return ['evidence.nextJobRecommendations must be an array'];
|
|
153
153
|
}
|
|
154
154
|
const errors = [];
|
|
155
|
+
if (value.length > 3) {
|
|
156
|
+
errors.push('evidence.nextJobRecommendations must contain at most 3 entries');
|
|
157
|
+
}
|
|
155
158
|
value.forEach((entry, i) => {
|
|
156
159
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
157
160
|
errors.push(`evidence.nextJobRecommendations[${i}] must be an object`);
|
|
158
161
|
return;
|
|
159
162
|
}
|
|
160
163
|
const rec = entry;
|
|
164
|
+
if ('jobName' in rec && !('jobId' in rec)) {
|
|
165
|
+
errors.push(`evidence.nextJobRecommendations[${i}] uses jobName, but runnable recommendations must use jobId. Use jobId, not jobName.`);
|
|
166
|
+
}
|
|
161
167
|
if (typeof rec.jobId !== 'string' || !rec.jobId.trim()) {
|
|
162
168
|
errors.push(`evidence.nextJobRecommendations[${i}].jobId must be a non-empty string`);
|
|
163
169
|
}
|
|
164
170
|
if (typeof rec.label !== 'string' || !rec.label.trim()) {
|
|
165
171
|
errors.push(`evidence.nextJobRecommendations[${i}].label must be a non-empty string`);
|
|
166
172
|
}
|
|
173
|
+
if (typeof rec.label === 'string' && rec.label.length > 60) {
|
|
174
|
+
errors.push(`evidence.nextJobRecommendations[${i}].label must be 60 characters or fewer`);
|
|
175
|
+
}
|
|
176
|
+
if (rec.reason !== undefined && typeof rec.reason !== 'string') {
|
|
177
|
+
errors.push(`evidence.nextJobRecommendations[${i}].reason must be a string when present`);
|
|
178
|
+
}
|
|
179
|
+
if (typeof rec.reason === 'string' && rec.reason.length > 200) {
|
|
180
|
+
errors.push(`evidence.nextJobRecommendations[${i}].reason must be 200 characters or fewer`);
|
|
181
|
+
}
|
|
182
|
+
if (rec.contextSummary !== undefined && typeof rec.contextSummary !== 'string') {
|
|
183
|
+
errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be a string when present`);
|
|
184
|
+
}
|
|
185
|
+
if (typeof rec.contextSummary === 'string' && rec.contextSummary.length > 300) {
|
|
186
|
+
errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be 300 characters or fewer`);
|
|
187
|
+
}
|
|
167
188
|
});
|
|
168
189
|
return errors.length > 0 ? errors : null;
|
|
169
190
|
}
|
|
@@ -172,6 +172,8 @@ class FraimDbService {
|
|
|
172
172
|
this.orgAuditCollection = this.db.collection('fraim_org_audit');
|
|
173
173
|
// Issue #1345 — job execution modes (Coached/Trusted).
|
|
174
174
|
this.jobExecutionModesCollection = this.db.collection('fraim_job_execution_modes');
|
|
175
|
+
// Issue #1431 — per-user referral codes.
|
|
176
|
+
this.referralCodesCollection = this.db.collection('fraim_referral_codes');
|
|
175
177
|
}
|
|
176
178
|
async initializeIndexes() {
|
|
177
179
|
if (!this.db)
|
|
@@ -252,6 +254,9 @@ class FraimDbService {
|
|
|
252
254
|
await this.auditLogCollection.createIndex({ sequence: 1 }).catch(() => { });
|
|
253
255
|
await this.auditLogCollection.createIndex({ userId: 1, ts: -1 }).catch(() => { });
|
|
254
256
|
await this.auditLogCollection.createIndex({ ts: -1 }).catch(() => { });
|
|
257
|
+
// Issue #1431 — referral codes: one code per user, globally unique codes.
|
|
258
|
+
await this.referralCodesCollection.createIndex({ userId: 1 }, { unique: true }).catch(() => { });
|
|
259
|
+
await this.referralCodesCollection.createIndex({ code: 1 }, { unique: true }).catch(() => { });
|
|
255
260
|
}
|
|
256
261
|
async createSession(session) {
|
|
257
262
|
if (!this.sessionsCollection)
|
|
@@ -613,6 +618,65 @@ class FraimDbService {
|
|
|
613
618
|
throw new Error('DB not connected');
|
|
614
619
|
return await this.signupsCollection.findOne({ email });
|
|
615
620
|
}
|
|
621
|
+
async setSignupAttribution(email, data) {
|
|
622
|
+
if (!this.signupsCollection)
|
|
623
|
+
throw new Error('DB not connected');
|
|
624
|
+
const now = new Date();
|
|
625
|
+
await this.signupsCollection.updateOne({ email }, {
|
|
626
|
+
$setOnInsert: {
|
|
627
|
+
email,
|
|
628
|
+
name: '',
|
|
629
|
+
company: '',
|
|
630
|
+
source: data.source,
|
|
631
|
+
timestamp: now,
|
|
632
|
+
ipAddress: data.ipAddress,
|
|
633
|
+
userAgent: data.userAgent,
|
|
634
|
+
...(data.referrerName ? { referrerName: data.referrerName } : {}),
|
|
635
|
+
...(data.referralCodeUsed ? { referralCodeUsed: data.referralCodeUsed } : {}),
|
|
636
|
+
},
|
|
637
|
+
}, { upsert: true });
|
|
638
|
+
}
|
|
639
|
+
generateReferralCodeString() {
|
|
640
|
+
const crypto = require('crypto');
|
|
641
|
+
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
642
|
+
const bytes = crypto.randomBytes(12);
|
|
643
|
+
return Array.from(bytes).map((b) => ALPHABET[b % ALPHABET.length]).join('');
|
|
644
|
+
}
|
|
645
|
+
async getReferralCode(userId) {
|
|
646
|
+
if (!this.referralCodesCollection)
|
|
647
|
+
throw new Error('DB not connected');
|
|
648
|
+
return await this.referralCodesCollection.findOne({ userId });
|
|
649
|
+
}
|
|
650
|
+
async getOrCreateReferralCode(userId) {
|
|
651
|
+
if (!this.referralCodesCollection)
|
|
652
|
+
throw new Error('DB not connected');
|
|
653
|
+
const existing = await this.referralCodesCollection.findOne({ userId });
|
|
654
|
+
if (existing)
|
|
655
|
+
return existing.code;
|
|
656
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
657
|
+
const code = this.generateReferralCodeString();
|
|
658
|
+
try {
|
|
659
|
+
await this.referralCodesCollection.insertOne({ userId, code, createdAt: new Date() });
|
|
660
|
+
return code;
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
if (err?.code === 11000) {
|
|
664
|
+
const row = await this.referralCodesCollection.findOne({ userId });
|
|
665
|
+
if (row)
|
|
666
|
+
return row.code;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
throw new Error('Failed to generate unique referral code after retries');
|
|
673
|
+
}
|
|
674
|
+
async validateReferralCode(code) {
|
|
675
|
+
if (!this.referralCodesCollection)
|
|
676
|
+
throw new Error('DB not connected');
|
|
677
|
+
const exists = await this.referralCodesCollection.findOne({ code });
|
|
678
|
+
return exists !== null;
|
|
679
|
+
}
|
|
616
680
|
/** Get existing API key by userId (email for self-serve) */
|
|
617
681
|
async getApiKeyByUserId(userId, activeOnly = true) {
|
|
618
682
|
if (!this.db)
|
|
@@ -53,6 +53,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
53
53
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
54
|
exports.FraimLocalMCPServer = exports.FraimTemplateEngine = void 0;
|
|
55
55
|
exports.parseExecutionModeFromResponse = parseExecutionModeFromResponse;
|
|
56
|
+
exports.buildExecutionModeForwardUrl = buildExecutionModeForwardUrl;
|
|
56
57
|
const fs_1 = require("fs");
|
|
57
58
|
const path_1 = require("path");
|
|
58
59
|
const os_1 = require("os");
|
|
@@ -450,6 +451,19 @@ function parseExecutionModeFromResponse(text) {
|
|
|
450
451
|
return null;
|
|
451
452
|
}
|
|
452
453
|
}
|
|
454
|
+
// Issue #1443: the Hub's run registry is keyed by the host CLI's own session id
|
|
455
|
+
// (run.sessionId, discovered from host stdout well after spawn — see
|
|
456
|
+
// applyRunHostSessionSignal in ai-hub/server.ts), which has no relationship to the
|
|
457
|
+
// FRAIM/MCP session id this proxy carries on every tool call. A lookup by the latter
|
|
458
|
+
// never matches. FRAIM_HUB_RUN_ID is stamped into this process's env at spawn time
|
|
459
|
+
// (AiHubServer.withHubRunIdEnv) specifically so this forward can target the run
|
|
460
|
+
// directly; fall back to the (unreliable) session-based route only when the Hub that
|
|
461
|
+
// spawned this process predates that fix.
|
|
462
|
+
function buildExecutionModeForwardUrl(hubBase, hubRunId, requestSessionId) {
|
|
463
|
+
return hubRunId
|
|
464
|
+
? `${hubBase}/api/ai-hub/runs/${encodeURIComponent(hubRunId)}/execution-mode`
|
|
465
|
+
: `${hubBase}/api/ai-hub/runs/by-session/${encodeURIComponent(requestSessionId)}/execution-mode`;
|
|
466
|
+
}
|
|
453
467
|
class FraimLocalMCPServer {
|
|
454
468
|
constructor(writer) {
|
|
455
469
|
this.config = null;
|
|
@@ -1427,7 +1441,8 @@ class FraimLocalMCPServer {
|
|
|
1427
1441
|
const hubBase = process.env.FRAIM_HUB_BASE_URL;
|
|
1428
1442
|
const em = parseExecutionModeFromResponse(responseText);
|
|
1429
1443
|
if (hubBase && em) {
|
|
1430
|
-
|
|
1444
|
+
const forwardUrl = buildExecutionModeForwardUrl(hubBase, process.env.FRAIM_HUB_RUN_ID, requestSessionId);
|
|
1445
|
+
axios_1.default.post(forwardUrl, em, { headers: { 'Content-Type': 'application/json' } }).catch((err) => {
|
|
1431
1446
|
this.log(`[req:${requestId}] execution-mode forward failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1432
1447
|
});
|
|
1433
1448
|
}
|
|
@@ -1500,6 +1515,25 @@ class FraimLocalMCPServer {
|
|
|
1500
1515
|
return null;
|
|
1501
1516
|
}
|
|
1502
1517
|
}
|
|
1518
|
+
async validateNextJobRecommendationJobIds(value, mentor) {
|
|
1519
|
+
if (!Array.isArray(value))
|
|
1520
|
+
return [];
|
|
1521
|
+
const errors = [];
|
|
1522
|
+
for (let i = 0; i < value.length; i++) {
|
|
1523
|
+
const entry = value[i];
|
|
1524
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
1525
|
+
continue;
|
|
1526
|
+
const jobId = entry.jobId;
|
|
1527
|
+
if (typeof jobId !== 'string' || !jobId.trim())
|
|
1528
|
+
continue;
|
|
1529
|
+
const normalizedJobId = jobId.trim();
|
|
1530
|
+
const job = await mentor.getJobOverview(normalizedJobId);
|
|
1531
|
+
if (!job) {
|
|
1532
|
+
errors.push(`evidence.nextJobRecommendations[${i}].jobId must reference a known FRAIM job (got "${normalizedJobId}")`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return errors;
|
|
1536
|
+
}
|
|
1503
1537
|
async finalizeLocalToolTextResponse(request, requestSessionId, requestId, text) {
|
|
1504
1538
|
const response = {
|
|
1505
1539
|
jsonrpc: '2.0',
|
|
@@ -2255,6 +2289,7 @@ class FraimLocalMCPServer {
|
|
|
2255
2289
|
findings: args.findings,
|
|
2256
2290
|
phases: handoffPhaseMap,
|
|
2257
2291
|
});
|
|
2292
|
+
handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
|
|
2258
2293
|
if (handoffErrors.length > 0) {
|
|
2259
2294
|
const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
|
|
2260
2295
|
: handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
|
|
@@ -38,7 +38,7 @@ class AuthMiddleware {
|
|
|
38
38
|
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${mcpResourceMetadataUrl}"`);
|
|
39
39
|
}
|
|
40
40
|
};
|
|
41
|
-
const publicPrefixes = ['/admin', '/dashboard', '/health', '/pricing', '/fraim-brain', '/installers', '/api/signup', '/api/sales', '/api/request-access', '/api/installer-key', '/api/installer-download', '/api/installer-availability', '/api/payment/bypass', '/api/pricing', '/api/personas/catalog', '/api/ai-hub/ui/releases', '/ai-hub-remote', '/auth', '/portfolio'];
|
|
41
|
+
const publicPrefixes = ['/admin', '/dashboard', '/health', '/pricing', '/fraim-brain', '/installers', '/api/signup', '/api/sales', '/api/request-access', '/api/installer-key', '/api/installer-download', '/api/installer-availability', '/api/payment/bypass', '/api/pricing', '/api/personas/catalog', '/api/ai-hub/ui/releases', '/ai-hub-remote', '/auth', '/portfolio', '/api/referral'];
|
|
42
42
|
// Analytics dashboard is public, but API routes are protected
|
|
43
43
|
const isAnalyticsPublic = p === '/analytics' || p === '/analytics/' || p.startsWith('/analytics/') && p.endsWith('.html');
|
|
44
44
|
const isOAuthMetadataPublic = p === '/.well-known/oauth-authorization-server'
|
|
@@ -417,6 +417,45 @@ function registerProtectedAuthRoutes(app, deps) {
|
|
|
417
417
|
return res.status(500).json({ ok: false });
|
|
418
418
|
}
|
|
419
419
|
});
|
|
420
|
+
app.get('/api/account/referral-code', async (req, res) => {
|
|
421
|
+
try {
|
|
422
|
+
const apiKeyData = req.apiKeyData;
|
|
423
|
+
if (!apiKeyData)
|
|
424
|
+
return res.status(401).json({ ok: false });
|
|
425
|
+
const record = await dbService.getReferralCode(apiKeyData.userId);
|
|
426
|
+
return res.status(200).json({ code: record?.code ?? null });
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
console.error('[FRAIM AUTH] /api/account/referral-code GET error:', err instanceof Error ? err.message : String(err));
|
|
430
|
+
return res.status(500).json({ ok: false });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
app.post('/api/account/referral-code', async (req, res) => {
|
|
434
|
+
try {
|
|
435
|
+
const apiKeyData = req.apiKeyData;
|
|
436
|
+
if (!apiKeyData)
|
|
437
|
+
return res.status(401).json({ ok: false });
|
|
438
|
+
const code = await dbService.getOrCreateReferralCode(apiKeyData.userId);
|
|
439
|
+
return res.status(200).json({ code });
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
console.error('[FRAIM AUTH] /api/account/referral-code POST error:', err instanceof Error ? err.message : String(err));
|
|
443
|
+
return res.status(500).json({ ok: false });
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
app.get('/api/referral/validate', async (req, res) => {
|
|
447
|
+
try {
|
|
448
|
+
const code = String(req.query.code || '').trim().toUpperCase();
|
|
449
|
+
if (!code)
|
|
450
|
+
return res.status(200).json({ valid: false });
|
|
451
|
+
const valid = await dbService.validateReferralCode(code);
|
|
452
|
+
return res.status(200).json({ valid });
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
console.error('[FRAIM AUTH] /api/referral/validate error:', err instanceof Error ? err.message : String(err));
|
|
456
|
+
return res.status(500).json({ valid: false });
|
|
457
|
+
}
|
|
458
|
+
});
|
|
420
459
|
app.get('/api/account/activity', async (req, res) => {
|
|
421
460
|
try {
|
|
422
461
|
const apiKeyData = req.apiKeyData;
|
|
@@ -118,7 +118,7 @@ function registerOAuthRoutes(app, deps) {
|
|
|
118
118
|
});
|
|
119
119
|
// Helper used by both providers to finalise authentication once we have a verified email.
|
|
120
120
|
async function completeSignIn(req, res, params) {
|
|
121
|
-
const { verifiedEmail, provider, surface, redirectTo, intent, hubReturn } = params;
|
|
121
|
+
const { verifiedEmail, provider, surface, redirectTo, intent, hubReturn, referrerName, referralCode } = params;
|
|
122
122
|
const lower = verifiedEmail.toLowerCase();
|
|
123
123
|
const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
|
|
124
124
|
let apiKeyForHubReturn = apiKeyData?.key ?? '';
|
|
@@ -141,6 +141,17 @@ function registerOAuthRoutes(app, deps) {
|
|
|
141
141
|
}
|
|
142
142
|
if (!apiKeyData && intent === 'signup') {
|
|
143
143
|
apiKeyForHubReturn = await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
|
|
144
|
+
// Issue #1431: write attribution once for new OAuth accounts. Uses upsert
|
|
145
|
+
// with $setOnInsert so repeat signups never overwrite original attribution.
|
|
146
|
+
if (referrerName || referralCode) {
|
|
147
|
+
await dbService.setSignupAttribution(lower, {
|
|
148
|
+
source: `oauth:${provider}`,
|
|
149
|
+
...(referrerName ? { referrerName } : {}),
|
|
150
|
+
...(referralCode ? { referralCodeUsed: referralCode.toUpperCase() } : {}),
|
|
151
|
+
userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined,
|
|
152
|
+
ipAddress: (0, oauth_helpers_1.clientIpFromReq)(req),
|
|
153
|
+
}).catch(() => { });
|
|
154
|
+
}
|
|
144
155
|
}
|
|
145
156
|
const sessionId = (0, oauth_helpers_1.generateSessionId)();
|
|
146
157
|
await dbService.createAuthSession({
|
|
@@ -174,6 +185,8 @@ function registerOAuthRoutes(app, deps) {
|
|
|
174
185
|
const intent = pickIntent(req.query.intent);
|
|
175
186
|
const redirectTo = (0, oauth_helpers_1.safeRedirectPath)(req.query.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
|
|
176
187
|
const hubReturn = safeLoopbackHubReturn(req.query.hub_return);
|
|
188
|
+
const referrerName = typeof req.query.referrer_name === 'string' ? req.query.referrer_name.trim().slice(0, 200) : undefined;
|
|
189
|
+
const referralCode = typeof req.query.referral_code === 'string' ? req.query.referral_code.trim().toUpperCase().slice(0, 20) : undefined;
|
|
177
190
|
try {
|
|
178
191
|
const codeVerifier = (0, oauth_helpers_1.generatePkceVerifier)();
|
|
179
192
|
const stateNonce = (0, oauth_helpers_1.generateStateNonce)();
|
|
@@ -187,6 +200,8 @@ function registerOAuthRoutes(app, deps) {
|
|
|
187
200
|
stateNonce,
|
|
188
201
|
redirectTo,
|
|
189
202
|
hubReturn,
|
|
203
|
+
...(referrerName ? { referrerName } : {}),
|
|
204
|
+
...(referralCode ? { referralCode } : {}),
|
|
190
205
|
});
|
|
191
206
|
(0, cookie_service_1.setOAuthPendingCookie)(res, pendingId);
|
|
192
207
|
await (0, audit_log_1.auditLog)('OAUTH_START', { provider, surface, ip: (0, oauth_helpers_1.clientIpFromReq)(req), outcome: 'success' });
|
|
@@ -297,6 +312,8 @@ function registerOAuthRoutes(app, deps) {
|
|
|
297
312
|
redirectTo: pending.redirectTo,
|
|
298
313
|
intent: pickIntent(pending.intent),
|
|
299
314
|
hubReturn: pending.hubReturn,
|
|
315
|
+
referrerName: pending.referrerName,
|
|
316
|
+
referralCode: pending.referralCode,
|
|
300
317
|
});
|
|
301
318
|
}
|
|
302
319
|
catch (err) {
|
|
@@ -391,6 +408,8 @@ function registerOAuthRoutes(app, deps) {
|
|
|
391
408
|
redirectTo: pending.redirectTo,
|
|
392
409
|
intent: pickIntent(pending.intent),
|
|
393
410
|
hubReturn: pending.hubReturn,
|
|
411
|
+
referrerName: pending.referrerName,
|
|
412
|
+
referralCode: pending.referralCode,
|
|
394
413
|
});
|
|
395
414
|
}
|
|
396
415
|
catch (err) {
|
|
@@ -130,7 +130,7 @@ class AdminService {
|
|
|
130
130
|
* Security: prevents unauthorized access by requiring email ownership verification.
|
|
131
131
|
*/
|
|
132
132
|
async requestAccess(body, req) {
|
|
133
|
-
const { email, name, company, useCase } = body;
|
|
133
|
+
const { email, name, company, useCase, referrerName, referralCode } = body;
|
|
134
134
|
// Issue #691: signup is now email + verification code only. Name/company
|
|
135
135
|
// are optional lead-capture fields (createWebsiteSignup below already
|
|
136
136
|
// tolerates missing values); the trial key itself never required them.
|
|
@@ -150,11 +150,14 @@ class AdminService {
|
|
|
150
150
|
source: 'request-access',
|
|
151
151
|
timestamp: new Date(),
|
|
152
152
|
ipAddress,
|
|
153
|
-
userAgent
|
|
153
|
+
userAgent,
|
|
154
|
+
...(referrerName ? { referrerName: referrerName.trim() } : {}),
|
|
155
|
+
...(referralCode ? { referralCodeUsed: referralCode.trim().toUpperCase() } : {}),
|
|
154
156
|
});
|
|
155
157
|
}
|
|
156
158
|
catch {
|
|
157
159
|
// Ignore duplicate or other signup errors; email verification still proceeds.
|
|
160
|
+
// Attribution write-once: duplicate emails silently fail, preserving first attribution.
|
|
158
161
|
}
|
|
159
162
|
const verificationToken = (0, crypto_1.randomBytes)(32).toString('hex');
|
|
160
163
|
const verificationCode = (0, email_code_1.generateEmailCode)();
|
|
@@ -166,7 +169,9 @@ class AdminService {
|
|
|
166
169
|
verified: false,
|
|
167
170
|
expiresAt,
|
|
168
171
|
createdAt: new Date(),
|
|
169
|
-
usedAt: null
|
|
172
|
+
usedAt: null,
|
|
173
|
+
...(referrerName ? { referrerName: referrerName.trim() } : {}),
|
|
174
|
+
...(referralCode ? { referralCode: referralCode.trim().toUpperCase() } : {}),
|
|
170
175
|
});
|
|
171
176
|
const emailService = new email_service_1.EmailService();
|
|
172
177
|
const baseUrl = process.env.BASE_URL || 'https://fraimworks.ai';
|