fraim 2.0.297 → 2.0.299
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/cli/utils/user-config.js +20 -0
- package/dist/src/config/persona-capability-bundles.js +10 -1
- package/dist/src/config/persona-hiring.js +10 -0
- package/dist/src/core/ai-mentor.js +9 -2
- package/dist/src/core/handoff-contracts.js +114 -4
- package/dist/src/core/resolve-phase-edge.js +3 -1
- package/dist/src/fraim/db-service.js +64 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +11 -7
- package/dist/src/local-mcp-server/learning-usage-attestation.js +11 -4
- 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
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.getLayerBackend = getLayerBackend;
|
|
7
|
+
exports.isLayerMisconfigured = isLayerMisconfigured;
|
|
7
8
|
exports.setLayerBackend = setLayerBackend;
|
|
8
9
|
exports.readUserFraimConfig = readUserFraimConfig;
|
|
9
10
|
exports.writeUserFraimConfig = writeUserFraimConfig;
|
|
@@ -29,6 +30,25 @@ function getLayerBackend(layer) {
|
|
|
29
30
|
return getManagerStorageConfig();
|
|
30
31
|
return null;
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Returns true when the layer has a config entry whose backend is recognized
|
|
35
|
+
* but the required pointer field is missing (git with no gitUrl, local-folder
|
|
36
|
+
* with no localPath). Callers use this to distinguish "not configured" from
|
|
37
|
+
* "misconfigured" so the UI can surface a warning instead of silently falling
|
|
38
|
+
* back to single-machine. (issue #1465, R3)
|
|
39
|
+
*/
|
|
40
|
+
function isLayerMisconfigured(layer) {
|
|
41
|
+
const raw = layer === 'org'
|
|
42
|
+
? readUserFraimConfig().organization
|
|
43
|
+
: readUserFraimConfig().managerStorage;
|
|
44
|
+
if (!raw || typeof raw !== 'object')
|
|
45
|
+
return false;
|
|
46
|
+
if (raw.backend === 'git' && !raw.gitUrl)
|
|
47
|
+
return true;
|
|
48
|
+
if (raw.backend === 'local-folder' && !raw.localPath)
|
|
49
|
+
return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
32
52
|
/** Write the backend config for a named layer (issue #834). */
|
|
33
53
|
function setLayerBackend(layer, config) {
|
|
34
54
|
if (layer === 'org') {
|
|
@@ -113,11 +113,20 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
|
|
|
113
113
|
defaultHireMode: 'job',
|
|
114
114
|
lockCopy: 'Hire SEChar to unlock security setup, review, and findings-command work for this request.'
|
|
115
115
|
},
|
|
116
|
+
travis: {
|
|
117
|
+
personaKey: 'travis',
|
|
118
|
+
bundleId: 'persona-travis-core',
|
|
119
|
+
catalogMetadata: buildCatalogMetadata('travis', ['travel-planning', 'travel-disruption-rebooking', 'travel-fare-watch']),
|
|
120
|
+
protectedJobs: ['travel-planning', 'travel-disruption-rebooking', 'travel-fare-watch'],
|
|
121
|
+
protectedAliases: ['travel-agent', 'travel', 'trip-planning', 'rebooking', 'fare-watch', 'flight-watch'],
|
|
122
|
+
defaultHireMode: 'job',
|
|
123
|
+
lockCopy: 'Hire TRAVis to unlock trip planning, fare watching, travel disruption handling, and rebooking support for this request.'
|
|
124
|
+
},
|
|
116
125
|
ashley: {
|
|
117
126
|
personaKey: 'ashley',
|
|
118
127
|
bundleId: 'persona-ashley-core',
|
|
119
128
|
catalogMetadata: buildCatalogMetadata('ashley', ['chief-of-staff-briefing', 'executive-assistant', 'analyze-transcript']),
|
|
120
|
-
protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'analyze-transcript'
|
|
129
|
+
protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'analyze-transcript'],
|
|
121
130
|
protectedAliases: ['executive-assistant', 'operations-assistant'],
|
|
122
131
|
defaultHireMode: 'job',
|
|
123
132
|
lockCopy: 'Hire AshLey to unlock executive-assistant work for this request.'
|
|
@@ -199,6 +199,15 @@ exports.PERSONA_HIRE_CATALOG = {
|
|
|
199
199
|
jobPriceCents: 890,
|
|
200
200
|
fulltimePriceCents: 4990,
|
|
201
201
|
},
|
|
202
|
+
travis: {
|
|
203
|
+
displayName: 'TRAVis',
|
|
204
|
+
role: 'Travel Agent',
|
|
205
|
+
emoji: '\u{1F9ED}',
|
|
206
|
+
gradient: 'linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%)',
|
|
207
|
+
blurb: 'Plans trips, watches fares, handles travel disruptions, and helps with rebooking options while keeping provider limits and booking boundaries clear.',
|
|
208
|
+
jobPriceCents: 890,
|
|
209
|
+
fulltimePriceCents: 4990,
|
|
210
|
+
},
|
|
202
211
|
ashley: {
|
|
203
212
|
displayName: 'AshLey',
|
|
204
213
|
role: 'Executive Assistant',
|
|
@@ -251,6 +260,7 @@ exports.PERSONA_AVATAR_CATALOG = {
|
|
|
251
260
|
ricardo: { seed: 'RICARDO-recruiter', bg: 'ede9fe', style: 'notionists' },
|
|
252
261
|
hari: { seed: 'HARI-hr', bg: 'ccfbf1', style: 'notionists' },
|
|
253
262
|
careena: { seed: 'CAREENA-career-coach', bg: 'e0f2fe', style: 'notionists' },
|
|
263
|
+
travis: { seed: 'TRAVIS-travel-agent', bg: 'ccfbf1', style: 'notionists' },
|
|
254
264
|
sade: { seed: 'SADE-salesforce', bg: 'bae6fd', style: 'notionists' },
|
|
255
265
|
sam: { seed: 'SAM-sales-account', bg: 'd1fae5', style: 'notionists' },
|
|
256
266
|
casey: { seed: 'CASEY-cx', bg: 'a5f3fc', style: 'notionists' },
|
|
@@ -83,7 +83,7 @@ class AIMentor {
|
|
|
83
83
|
const validOutcomes = (0, resolve_phase_edge_1.discriminantKeys)(onSuccess)
|
|
84
84
|
.map((key) => `"${key}"`)
|
|
85
85
|
.join(' | ');
|
|
86
|
-
return `\n\n---\n\n## Report Back\nWhen this phase is done, call \`seekMentoring({ jobName: "${jobName}", issueNumber: "<issue_number>", currentPhase: "${phaseId}", status: "complete", findings: {
|
|
86
|
+
return `\n\n---\n\n## Report Back\nWhen this phase is done, call \`seekMentoring({ jobName: "${jobName}", issueNumber: "<issue_number>", currentPhase: "${phaseId}", status: "complete", findings: { phaseOutcome: "<outcome>" } })\` with one of: ${validOutcomes}.${failureNote}`;
|
|
87
87
|
}
|
|
88
88
|
/** Phase-authority content injected for all phased workflows. Loaded from orchestration/phase-authority.md. */
|
|
89
89
|
async getPhaseAuthorityContent() {
|
|
@@ -173,7 +173,14 @@ class AIMentor {
|
|
|
173
173
|
nextPhaseId = (0, resolve_phase_edge_1.resolvePhaseEdge)(phaseFlow.onSuccess, (0, resolve_phase_edge_1.resolveDiscriminant)(findings, evidence));
|
|
174
174
|
}
|
|
175
175
|
let message = '';
|
|
176
|
-
if (nextPhaseId) {
|
|
176
|
+
if (nextPhaseId === resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION) {
|
|
177
|
+
message += [
|
|
178
|
+
'### Waiting For Next Invocation',
|
|
179
|
+
'',
|
|
180
|
+
'Send a concise status update that this recurring assignment is waiting for its next scheduled check, then stop.'
|
|
181
|
+
].join('\n');
|
|
182
|
+
}
|
|
183
|
+
else if (nextPhaseId) {
|
|
177
184
|
message += `Moving to the next phase: **${nextPhaseId}**.\n\n`;
|
|
178
185
|
let nextInstructions = workflow.phases.get(nextPhaseId);
|
|
179
186
|
if (nextInstructions) {
|
|
@@ -96,6 +96,42 @@ function validateAddressFeedbackApproval(evidence) {
|
|
|
96
96
|
}
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
|
+
function isAbsoluteHttpUrl(url) {
|
|
100
|
+
if (typeof url !== 'string' || !url)
|
|
101
|
+
return false;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = new URL(url);
|
|
104
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function validateReviewArtifactEntry(artifact, index) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
const label = `evidence.reviewHandoff.artifacts[${index}]`;
|
|
113
|
+
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
|
114
|
+
errors.push(`${label} must be an object`);
|
|
115
|
+
return errors;
|
|
116
|
+
}
|
|
117
|
+
const entry = artifact;
|
|
118
|
+
if ('kind' in entry && !('type' in entry)) {
|
|
119
|
+
errors.push(`${label} uses kind but must use type — use type, not kind for artifact type (e.g. "markdown", "docx", "source_file")`);
|
|
120
|
+
}
|
|
121
|
+
else if (!entry.type || typeof entry.type !== 'string' || !entry.type.trim()) {
|
|
122
|
+
errors.push(`${label}.type must be a non-empty string (e.g. "markdown", "docx", "source_file")`);
|
|
123
|
+
}
|
|
124
|
+
if (!entry.label || typeof entry.label !== 'string' || !entry.label.trim()) {
|
|
125
|
+
errors.push(`${label}.label must be a non-empty string`);
|
|
126
|
+
}
|
|
127
|
+
if ('url' in entry) {
|
|
128
|
+
errors.push(`${label} must not include url — artifact_set artifacts are local files; use path`);
|
|
129
|
+
}
|
|
130
|
+
if (!entry.path || typeof entry.path !== 'string' || !entry.path.trim()) {
|
|
131
|
+
errors.push(`${label}.path must be a non-empty local path`);
|
|
132
|
+
}
|
|
133
|
+
return errors;
|
|
134
|
+
}
|
|
99
135
|
/**
|
|
100
136
|
* Validates evidence.reviewHandoff. Returns null if valid, or an array of
|
|
101
137
|
* human-readable error strings describing what is wrong.
|
|
@@ -103,9 +139,16 @@ function validateAddressFeedbackApproval(evidence) {
|
|
|
103
139
|
* Required minimum shape:
|
|
104
140
|
* {
|
|
105
141
|
* reviewRequired: boolean,
|
|
106
|
-
* reviewTarget: object | null,
|
|
107
142
|
* artifacts: array,
|
|
143
|
+
* // when reviewRequired is true:
|
|
144
|
+
* reviewTarget: { type: "pull_request", label: string, url: string } | { type: "artifact_set", label: string },
|
|
145
|
+
* reviewActions: [{ kind: "approve", ... }, { kind: "request_changes", ... }],
|
|
108
146
|
* }
|
|
147
|
+
*
|
|
148
|
+
* Hub normalizers (server.ts + script.js) both read reviewTarget.type, not .kind.
|
|
149
|
+
* Using reviewTarget.kind produces a payload that passes validation but is silently
|
|
150
|
+
* un-renderable. This validator enforces the renderable contract so malformed payloads
|
|
151
|
+
* are rejected before phase advancement.
|
|
109
152
|
*/
|
|
110
153
|
function validateReviewHandoff(value) {
|
|
111
154
|
if (value === undefined || value === null) {
|
|
@@ -119,7 +162,42 @@ function validateReviewHandoff(value) {
|
|
|
119
162
|
if (typeof obj.reviewRequired !== 'boolean') {
|
|
120
163
|
errors.push(`evidence.reviewHandoff.reviewRequired must be a boolean (got ${obj.reviewRequired === undefined ? 'missing' : typeof obj.reviewRequired})`);
|
|
121
164
|
}
|
|
165
|
+
// artifacts must always be present and be an array
|
|
166
|
+
if (!('artifacts' in obj) || !Array.isArray(obj.artifacts)) {
|
|
167
|
+
errors.push('evidence.reviewHandoff.artifacts must be an array');
|
|
168
|
+
}
|
|
122
169
|
if (obj.reviewRequired === true) {
|
|
170
|
+
const target = obj.reviewTarget;
|
|
171
|
+
if (target === undefined || target === null) {
|
|
172
|
+
errors.push('evidence.reviewHandoff.reviewTarget must be a non-null object with type equal to "pull_request" or "artifact_set" when reviewRequired is true');
|
|
173
|
+
}
|
|
174
|
+
else if (typeof target !== 'object' || Array.isArray(target)) {
|
|
175
|
+
errors.push('evidence.reviewHandoff.reviewTarget must be an object with type equal to "pull_request" or "artifact_set"');
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const t = target;
|
|
179
|
+
if ('kind' in t && !('type' in t)) {
|
|
180
|
+
errors.push(`evidence.reviewHandoff.reviewTarget uses kind but must use type — use type, not kind (e.g. { type: "pull_request", label: "...", url: "https://..." })`);
|
|
181
|
+
}
|
|
182
|
+
else if (t.type !== 'pull_request' && t.type !== 'artifact_set') {
|
|
183
|
+
errors.push(`evidence.reviewHandoff.reviewTarget.type must be "pull_request" or "artifact_set" (got ${t.type === undefined ? 'missing' : JSON.stringify(t.type)})`);
|
|
184
|
+
}
|
|
185
|
+
else if (t.type === 'pull_request') {
|
|
186
|
+
if (!isAbsoluteHttpUrl(t.url)) {
|
|
187
|
+
errors.push(`evidence.reviewHandoff.reviewTarget.url must be an absolute http or https URL for a pull_request target (got ${t.url === undefined ? 'missing' : JSON.stringify(t.url)})`);
|
|
188
|
+
}
|
|
189
|
+
if (Array.isArray(obj.artifacts) && obj.artifacts.length > 0) {
|
|
190
|
+
errors.push('evidence.reviewHandoff.artifacts must be empty for a pull_request target — the PR is the complete review surface; set artifacts: []');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if (t.type === 'artifact_set') {
|
|
194
|
+
if (Array.isArray(obj.artifacts)) {
|
|
195
|
+
obj.artifacts.forEach((art, i) => {
|
|
196
|
+
errors.push(...validateReviewArtifactEntry(art, i));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
123
201
|
if (!Array.isArray(obj.reviewActions) || obj.reviewActions.length === 0) {
|
|
124
202
|
errors.push('evidence.reviewHandoff.reviewActions must be a non-empty array when reviewRequired is true');
|
|
125
203
|
}
|
|
@@ -135,6 +213,12 @@ function validateReviewHandoff(value) {
|
|
|
135
213
|
}
|
|
136
214
|
}
|
|
137
215
|
}
|
|
216
|
+
else if (obj.reviewRequired === false && Array.isArray(obj.artifacts)) {
|
|
217
|
+
// For reviewRequired:false, validate any supplied artifact entries (no approval semantics required)
|
|
218
|
+
obj.artifacts.forEach((art, i) => {
|
|
219
|
+
errors.push(...validateReviewArtifactEntry(art, i));
|
|
220
|
+
});
|
|
221
|
+
}
|
|
138
222
|
return errors.length > 0 ? errors : null;
|
|
139
223
|
}
|
|
140
224
|
/**
|
|
@@ -152,18 +236,39 @@ function validateNextJobRecommendations(value) {
|
|
|
152
236
|
return ['evidence.nextJobRecommendations must be an array'];
|
|
153
237
|
}
|
|
154
238
|
const errors = [];
|
|
239
|
+
if (value.length > 3) {
|
|
240
|
+
errors.push('evidence.nextJobRecommendations must contain at most 3 entries');
|
|
241
|
+
}
|
|
155
242
|
value.forEach((entry, i) => {
|
|
156
243
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
157
244
|
errors.push(`evidence.nextJobRecommendations[${i}] must be an object`);
|
|
158
245
|
return;
|
|
159
246
|
}
|
|
160
247
|
const rec = entry;
|
|
248
|
+
if ('jobName' in rec && !('jobId' in rec)) {
|
|
249
|
+
errors.push(`evidence.nextJobRecommendations[${i}] uses jobName, but runnable recommendations must use jobId. Use jobId, not jobName.`);
|
|
250
|
+
}
|
|
161
251
|
if (typeof rec.jobId !== 'string' || !rec.jobId.trim()) {
|
|
162
252
|
errors.push(`evidence.nextJobRecommendations[${i}].jobId must be a non-empty string`);
|
|
163
253
|
}
|
|
164
254
|
if (typeof rec.label !== 'string' || !rec.label.trim()) {
|
|
165
255
|
errors.push(`evidence.nextJobRecommendations[${i}].label must be a non-empty string`);
|
|
166
256
|
}
|
|
257
|
+
if (typeof rec.label === 'string' && rec.label.length > 60) {
|
|
258
|
+
errors.push(`evidence.nextJobRecommendations[${i}].label must be 60 characters or fewer`);
|
|
259
|
+
}
|
|
260
|
+
if (rec.reason !== undefined && typeof rec.reason !== 'string') {
|
|
261
|
+
errors.push(`evidence.nextJobRecommendations[${i}].reason must be a string when present`);
|
|
262
|
+
}
|
|
263
|
+
if (typeof rec.reason === 'string' && rec.reason.length > 200) {
|
|
264
|
+
errors.push(`evidence.nextJobRecommendations[${i}].reason must be 200 characters or fewer`);
|
|
265
|
+
}
|
|
266
|
+
if (rec.contextSummary !== undefined && typeof rec.contextSummary !== 'string') {
|
|
267
|
+
errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be a string when present`);
|
|
268
|
+
}
|
|
269
|
+
if (typeof rec.contextSummary === 'string' && rec.contextSummary.length > 300) {
|
|
270
|
+
errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be 300 characters or fewer`);
|
|
271
|
+
}
|
|
167
272
|
});
|
|
168
273
|
return errors.length > 0 ? errors : null;
|
|
169
274
|
}
|
|
@@ -282,8 +387,12 @@ const REVIEW_HANDOFF_SCHEMA = `\`\`\`javascript
|
|
|
282
387
|
evidence: {
|
|
283
388
|
reviewHandoff: {
|
|
284
389
|
reviewRequired: true,
|
|
285
|
-
|
|
286
|
-
|
|
390
|
+
// Pull request review (PR is the complete review surface -- set artifacts: []):
|
|
391
|
+
reviewTarget: { type: "pull_request", label: "Pull request", url: "https://github.com/org/repo/pull/N" },
|
|
392
|
+
artifacts: [],
|
|
393
|
+
// -- OR -- artifact set review (local generated files):
|
|
394
|
+
// reviewTarget: { type: "artifact_set", label: "Review bundle" },
|
|
395
|
+
// artifacts: [{ type: "markdown", label: "<label>", path: "<local-path>" }],
|
|
287
396
|
summary: "<optional summary>",
|
|
288
397
|
reviewActions: [
|
|
289
398
|
{ kind: "approve", label: "Approve" },
|
|
@@ -291,7 +400,8 @@ evidence: {
|
|
|
291
400
|
]
|
|
292
401
|
}
|
|
293
402
|
}
|
|
294
|
-
|
|
403
|
+
\`\`\`
|
|
404
|
+
Note: reviewTarget uses "type" (not "kind"). Action entries use "kind" -- these are different fields.`;
|
|
295
405
|
const NEXT_JOB_RECOMMENDATIONS_SCHEMA = `\`\`\`javascript
|
|
296
406
|
evidence: {
|
|
297
407
|
nextJobRecommendations: [
|
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
* change inert for every job that authors no map.
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.FEEDBACK_PHASE_ID = void 0;
|
|
18
|
+
exports.FEEDBACK_PHASE_ID = exports.WAITING_FOR_NEXT_INVOCATION = void 0;
|
|
19
19
|
exports.resolvePhaseEdge = resolvePhaseEdge;
|
|
20
20
|
exports.resolveDiscriminant = resolveDiscriminant;
|
|
21
21
|
exports.discriminantKeys = discriminantKeys;
|
|
22
22
|
exports.derivePredecessorPhase = derivePredecessorPhase;
|
|
23
23
|
/** The default discriminant, and the mandatory key on every authored map. */
|
|
24
24
|
const DEFAULT_DISCRIMINANT = 'default';
|
|
25
|
+
/** Reserved transition target that parks a recurring job until the scheduler invokes it again. */
|
|
26
|
+
exports.WAITING_FOR_NEXT_INVOCATION = 'waiting-for-next-invocation';
|
|
25
27
|
/**
|
|
26
28
|
* Resolve an edge to the next phase id.
|
|
27
29
|
*
|
|
@@ -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)
|
|
@@ -1131,11 +1131,15 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1131
1131
|
if (key === 'org' || key === 'orgRules') {
|
|
1132
1132
|
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
|
|
1133
1133
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
1134
|
+
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
1135
|
+
const orgDisplayPath = cachePath.startsWith(fraimDir)
|
|
1136
|
+
? (0, project_fraim_paths_1.getUserFraimDisplayPath)(cachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, ''))
|
|
1137
|
+
: cachePath.replace(/\\/g, '/');
|
|
1134
1138
|
return {
|
|
1135
1139
|
present: true,
|
|
1136
1140
|
readPath: cachePath,
|
|
1137
1141
|
writePath: '',
|
|
1138
|
-
displayPath:
|
|
1142
|
+
displayPath: orgDisplayPath,
|
|
1139
1143
|
scope,
|
|
1140
1144
|
managedByOrgSync: true
|
|
1141
1145
|
};
|
|
@@ -1146,16 +1150,16 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1146
1150
|
const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
|
|
1147
1151
|
if ((0, fs_1.existsSync)(managerCachePath)) {
|
|
1148
1152
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
1149
|
-
const
|
|
1150
|
-
? managerCachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, '')
|
|
1151
|
-
:
|
|
1153
|
+
const mgrDisplayPath = managerCachePath.startsWith(fraimDir)
|
|
1154
|
+
? (0, project_fraim_paths_1.getUserFraimDisplayPath)(managerCachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, ''))
|
|
1155
|
+
: managerCachePath.replace(/\\/g, '/');
|
|
1152
1156
|
return {
|
|
1153
1157
|
present: true,
|
|
1154
1158
|
readPath: managerCachePath,
|
|
1155
|
-
writePath: '',
|
|
1156
|
-
displayPath:
|
|
1159
|
+
writePath: managerHome.requiresPublish ? '' : managerCachePath,
|
|
1160
|
+
displayPath: mgrDisplayPath,
|
|
1157
1161
|
scope,
|
|
1158
|
-
managedByManagerSync: true
|
|
1162
|
+
...(managerHome.requiresPublish ? { managedByManagerSync: true } : {})
|
|
1159
1163
|
};
|
|
1160
1164
|
}
|
|
1161
1165
|
}
|
|
@@ -25,15 +25,22 @@ const learning_context_builder_1 = require("./learning-context-builder");
|
|
|
25
25
|
var learning_firing_parser_2 = require("./learning-firing-parser");
|
|
26
26
|
Object.defineProperty(exports, "parseFiringSection", { enumerable: true, get: function () { return learning_firing_parser_2.parseFiringSection; } });
|
|
27
27
|
Object.defineProperty(exports, "extractLegacyTitle", { enumerable: true, get: function () { return learning_firing_parser_2.extractLegacyTitle; } });
|
|
28
|
+
const ENTRY_INDEX_READS = [
|
|
29
|
+
{ scope: 'manager', level: 'machine' },
|
|
30
|
+
{ scope: 'manager', level: 'project' },
|
|
31
|
+
{ scope: 'org', level: 'machine' },
|
|
32
|
+
{ scope: 'reverse', level: 'machine' },
|
|
33
|
+
{ scope: 'reverse', level: 'project' },
|
|
34
|
+
];
|
|
28
35
|
function buildEntryIndex(workspaceRoot, userId) {
|
|
29
36
|
const byId = new Map();
|
|
30
37
|
const byTitle = new Map();
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
for (const scope
|
|
38
|
+
// Keep this list in parity with the preserved-entry levels that the offer
|
|
39
|
+
// path can surface. Storage resolution stays inside readPreservedLearnings.
|
|
40
|
+
for (const { scope, level } of ENTRY_INDEX_READS) {
|
|
34
41
|
let entries = [];
|
|
35
42
|
try {
|
|
36
|
-
entries = (0, learning_context_builder_1.readPreservedLearnings)(workspaceRoot, userId, scope);
|
|
43
|
+
entries = (0, learning_context_builder_1.readPreservedLearnings)(workspaceRoot, userId, scope, level);
|
|
37
44
|
}
|
|
38
45
|
catch {
|
|
39
46
|
continue;
|
|
@@ -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';
|