fraim 2.0.296 → 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.
@@ -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
- axios_1.default.post(`${hubBase}/api/ai-hub/runs/by-session/${encodeURIComponent(requestSessionId)}/execution-mode`, em, { headers: { 'Content-Type': 'application/json' } }).catch((err) => {
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.296",
3
+ "version": "2.0.298",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {