fraim 2.0.351 → 2.0.353
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/api/payment/dashboard-link.js +42 -68
- package/dist/src/api/payment/webhook.js +63 -389
- package/dist/src/api/personas/me.js +4 -7
- package/dist/src/cli/commands/sync.js +3 -2
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +86 -9
- package/dist/src/cli/setup/user-level-sync.js +51 -7
- package/dist/src/cli/utils/managed-agent-paths.js +25 -9
- package/dist/src/core/handoff-contracts.js +8 -2
- package/dist/src/core/proposal-set-contract.js +105 -21
- package/dist/src/core/utils/server-startup.js +2 -2
- package/dist/src/fraim/current-license.js +51 -0
- package/dist/src/fraim/db-service.js +253 -30
- package/dist/src/fraim/license-presentation.js +41 -0
- package/dist/src/local-mcp-server/stdio-server.js +188 -73
- package/dist/src/local-mcp-server/usage-collector.js +20 -11
- package/dist/src/middleware/auth.js +44 -51
- package/dist/src/routes/analytics.js +3 -11
- package/dist/src/routes/auth-routes.js +4 -13
- package/dist/src/services/email-service.js +46 -32
- package/dist/src/services/mcp-service.js +17 -1
- package/dist/src/services/persona-entitlement-service.js +89 -31
- package/dist/src/services/subscription-payment.js +171 -0
- package/dist/src/services/subscription-provisioning.js +68 -0
- package/dist/src/services/subscription-reconciliation.js +147 -0
- package/package.json +1 -1
|
@@ -61,6 +61,9 @@ const child_process_1 = require("child_process");
|
|
|
61
61
|
const crypto_1 = require("crypto");
|
|
62
62
|
const local_runtime_1 = require("../../registry/scripts/learning-usage/local-runtime");
|
|
63
63
|
const axios_1 = __importDefault(require("axios"));
|
|
64
|
+
const http_1 = require("http");
|
|
65
|
+
const https_1 = require("https");
|
|
66
|
+
const tool_schemas_1 = require("../mcp/tool-schemas");
|
|
64
67
|
const provider_utils_1 = require("../core/utils/provider-utils");
|
|
65
68
|
const object_utils_1 = require("../core/utils/object-utils");
|
|
66
69
|
const redact_secrets_1 = require("../core/utils/redact-secrets");
|
|
@@ -525,6 +528,14 @@ function buildExecutionModeForwardUrl(hubBase, hubRunId, requestSessionId) {
|
|
|
525
528
|
}
|
|
526
529
|
class FraimLocalMCPServer {
|
|
527
530
|
constructor(writer) {
|
|
531
|
+
this.launchedAt = performance.now();
|
|
532
|
+
this.launcherStartedAt = Number(process.env.FRAIM_MCP_LAUNCH_STARTED_AT);
|
|
533
|
+
this.discoveryLogged = false;
|
|
534
|
+
this.firstToolLogged = false;
|
|
535
|
+
this.firstToolCompletedLogged = false;
|
|
536
|
+
this.sessionChecks = new Map();
|
|
537
|
+
this.httpAgent = new http_1.Agent({ keepAlive: true });
|
|
538
|
+
this.httpsAgent = new https_1.Agent({ keepAlive: true });
|
|
528
539
|
this.config = null;
|
|
529
540
|
this.clientSupportsRoots = false;
|
|
530
541
|
this.workspaceRoot = null;
|
|
@@ -575,6 +586,8 @@ class FraimLocalMCPServer {
|
|
|
575
586
|
this.usageUploadRequested = false;
|
|
576
587
|
// Telemetry is best effort: process exit does not wait for or replay uploads.
|
|
577
588
|
this.usageCollector.shutdown();
|
|
589
|
+
this.httpAgent.destroy();
|
|
590
|
+
this.httpsAgent.destroy();
|
|
578
591
|
if (this.otlpServer?.server) {
|
|
579
592
|
(0, otlp_metrics_receiver_js_1.stopOtlpReceiver)(this.otlpServer.server);
|
|
580
593
|
this.otlpServer = null;
|
|
@@ -847,6 +860,7 @@ class FraimLocalMCPServer {
|
|
|
847
860
|
await runSync({
|
|
848
861
|
projectRoot,
|
|
849
862
|
skipUpdates: true,
|
|
863
|
+
automatic: true,
|
|
850
864
|
local: this.shouldUseLocalSyncTarget(),
|
|
851
865
|
failHard: 'throw'
|
|
852
866
|
});
|
|
@@ -866,6 +880,8 @@ class FraimLocalMCPServer {
|
|
|
866
880
|
}
|
|
867
881
|
}
|
|
868
882
|
async ensureFreshLocalCatalogOnConnect(requestId) {
|
|
883
|
+
if (this.isShutdown)
|
|
884
|
+
return;
|
|
869
885
|
if (process.env.FRAIM_DISABLE_SYNC_ON_CONNECT === '1') {
|
|
870
886
|
this.latestConnectSyncWarning = null;
|
|
871
887
|
return;
|
|
@@ -890,9 +906,10 @@ class FraimLocalMCPServer {
|
|
|
890
906
|
}
|
|
891
907
|
this.log(`[req:${requestId}] Refreshing local FRAIM catalog in background because ${reason}`);
|
|
892
908
|
this.connectSyncInFlight = (async () => {
|
|
909
|
+
const refreshStartedAt = performance.now();
|
|
893
910
|
try {
|
|
894
911
|
await this.performLocalCatalogSync(projectRoot);
|
|
895
|
-
this.log(`[req:${requestId}] Local FRAIM catalog refresh complete`);
|
|
912
|
+
this.log(`[req:${requestId}] Local FRAIM catalog refresh complete in ${(performance.now() - refreshStartedAt).toFixed(0)} ms`);
|
|
896
913
|
}
|
|
897
914
|
catch (error) {
|
|
898
915
|
const message = error?.message || String(error);
|
|
@@ -2345,12 +2362,15 @@ class FraimLocalMCPServer {
|
|
|
2345
2362
|
}
|
|
2346
2363
|
const resolvedRequestStr = requestSubstitution.content;
|
|
2347
2364
|
const finalRequest = JSON.parse(resolvedRequestStr);
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
request.method === '
|
|
2352
|
-
|
|
2365
|
+
// Only pure reads may be replayed after a socket reset. Session creation and
|
|
2366
|
+
// workflow calls can have committed remotely even when the response is lost.
|
|
2367
|
+
const canRetryTransientRemoteReset = request.method === 'fraim/session/validate'
|
|
2368
|
+
|| (request.method === 'tools/call' && request.params?.name === 'get_fraim_file');
|
|
2369
|
+
const remoteStartedAt = performance.now();
|
|
2353
2370
|
const response = await this.postRemoteMcpRequest(finalRequest, headers, requestId, canRetryTransientRemoteReset);
|
|
2371
|
+
if (request.method === 'tools/call' && request.params?.name === 'fraim_connect') {
|
|
2372
|
+
this.log(`Remote session creation completed in ${(performance.now() - remoteStartedAt).toFixed(0)} ms`);
|
|
2373
|
+
}
|
|
2354
2374
|
return response.data;
|
|
2355
2375
|
}
|
|
2356
2376
|
catch (error) {
|
|
@@ -2407,10 +2427,9 @@ class FraimLocalMCPServer {
|
|
|
2407
2427
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
2408
2428
|
try {
|
|
2409
2429
|
return await axios_1.default.post(`${this.remoteUrl}/mcp`, requestBody, {
|
|
2410
|
-
headers
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
},
|
|
2430
|
+
headers,
|
|
2431
|
+
httpAgent: this.httpAgent,
|
|
2432
|
+
httpsAgent: this.httpsAgent,
|
|
2414
2433
|
timeout: 30000
|
|
2415
2434
|
});
|
|
2416
2435
|
}
|
|
@@ -2452,6 +2471,34 @@ class FraimLocalMCPServer {
|
|
|
2452
2471
|
this.loadConfig();
|
|
2453
2472
|
}
|
|
2454
2473
|
}
|
|
2474
|
+
async checkLocalWorkflowSession(sessionId, id) {
|
|
2475
|
+
if (!sessionId) {
|
|
2476
|
+
return { jsonrpc: '2.0', id, error: {
|
|
2477
|
+
code: -32001, message: 'FRAIM session is missing. Call fraim_connect first.'
|
|
2478
|
+
} };
|
|
2479
|
+
}
|
|
2480
|
+
let check = this.sessionChecks.get(sessionId);
|
|
2481
|
+
if (!check) {
|
|
2482
|
+
check = this._doProxyToRemote({
|
|
2483
|
+
jsonrpc: '2.0', id: (0, crypto_1.randomUUID)(), method: 'fraim/session/validate', params: { sessionId }
|
|
2484
|
+
});
|
|
2485
|
+
this.sessionChecks.set(sessionId, check);
|
|
2486
|
+
void check.finally(() => {
|
|
2487
|
+
if (this.sessionChecks.get(sessionId) === check)
|
|
2488
|
+
this.sessionChecks.delete(sessionId);
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
const response = await check;
|
|
2492
|
+
if (response.error || response.result?.valid !== true) {
|
|
2493
|
+
return { jsonrpc: '2.0', id, error: {
|
|
2494
|
+
code: -32001,
|
|
2495
|
+
message: response.error
|
|
2496
|
+
? `FRAIM session validation failed: ${response.error.message} Check connectivity and retry, or call fraim_connect again.`
|
|
2497
|
+
: 'FRAIM session could not be validated. Call fraim_connect again.'
|
|
2498
|
+
} };
|
|
2499
|
+
}
|
|
2500
|
+
return null;
|
|
2501
|
+
}
|
|
2455
2502
|
/**
|
|
2456
2503
|
* Handle incoming MCP request
|
|
2457
2504
|
*/
|
|
@@ -2459,6 +2506,32 @@ class FraimLocalMCPServer {
|
|
|
2459
2506
|
this.log(`📥 ${request.method}`);
|
|
2460
2507
|
const requestSessionId = this.extractSessionIdFromRequest(request);
|
|
2461
2508
|
const requestId = (0, crypto_1.randomUUID)();
|
|
2509
|
+
if (request.method === 'notifications/initialized')
|
|
2510
|
+
return null;
|
|
2511
|
+
if (request.method === 'tools/list') {
|
|
2512
|
+
if (!this.discoveryLogged) {
|
|
2513
|
+
this.discoveryLogged = true;
|
|
2514
|
+
this.log(`Local MCP discovery ready in ${(performance.now() - this.launchedAt).toFixed(0)} ms`);
|
|
2515
|
+
if (Number.isFinite(this.launcherStartedAt) && this.launcherStartedAt > 0 && this.launcherStartedAt <= Date.now()) {
|
|
2516
|
+
this.log(`MCP launcher to discovery completed in ${Date.now() - this.launcherStartedAt} ms`);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
return { jsonrpc: '2.0', id: request.id, result: {
|
|
2520
|
+
tools: (0, tool_schemas_1.getToolDefinitions)({ surface: 'local-proxy' })
|
|
2521
|
+
} };
|
|
2522
|
+
}
|
|
2523
|
+
if (request.method === 'ping')
|
|
2524
|
+
return { jsonrpc: '2.0', id: request.id, result: {} };
|
|
2525
|
+
if (request.method === 'resources/list') {
|
|
2526
|
+
return { jsonrpc: '2.0', id: request.id, result: { resources: [] } };
|
|
2527
|
+
}
|
|
2528
|
+
if (request.method === 'prompts/list') {
|
|
2529
|
+
return { jsonrpc: '2.0', id: request.id, result: { prompts: [] } };
|
|
2530
|
+
}
|
|
2531
|
+
if (request.method === 'tools/call' && !this.firstToolLogged) {
|
|
2532
|
+
this.firstToolLogged = true;
|
|
2533
|
+
this.log(`First MCP tool call began in ${(performance.now() - this.launchedAt).toFixed(0)} ms`);
|
|
2534
|
+
}
|
|
2462
2535
|
// Special handling for initialize request
|
|
2463
2536
|
if (request.method === 'initialize') {
|
|
2464
2537
|
// Check if client supports roots
|
|
@@ -2471,20 +2544,19 @@ class FraimLocalMCPServer {
|
|
|
2471
2544
|
this.clientSupportsRoots = false;
|
|
2472
2545
|
this.log(`❌ Client does NOT support roots capability`);
|
|
2473
2546
|
}
|
|
2474
|
-
//
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
this.loadConfig();
|
|
2482
|
-
if (this.clientSupportsRoots) {
|
|
2483
|
-
setImmediate(() => this.requestRootsFromClient());
|
|
2484
|
-
}
|
|
2547
|
+
// Load config and request roots without waiting for remote authentication.
|
|
2548
|
+
this.loadConfig();
|
|
2549
|
+
if (this.clientSupportsRoots) {
|
|
2550
|
+
setImmediate(() => this.requestRootsFromClient());
|
|
2551
|
+
}
|
|
2552
|
+
else {
|
|
2553
|
+
setImmediate(() => { void this.ensureFreshLocalCatalogOnConnect('startup'); });
|
|
2485
2554
|
}
|
|
2486
|
-
|
|
2487
|
-
|
|
2555
|
+
return { jsonrpc: '2.0', id: request.id, result: {
|
|
2556
|
+
protocolVersion: '2024-11-05',
|
|
2557
|
+
serverInfo: { name: 'fraim-mcp-server', version: this.localVersion },
|
|
2558
|
+
capabilities: { tools: {}, resources: {}, prompts: {} }
|
|
2559
|
+
} };
|
|
2488
2560
|
}
|
|
2489
2561
|
// Force ALL tools/call requests to return raw definitions so the proxy
|
|
2490
2562
|
// can resolve templates and replace includes locally
|
|
@@ -2492,6 +2564,11 @@ class FraimLocalMCPServer {
|
|
|
2492
2564
|
const args = request.params?.arguments || {};
|
|
2493
2565
|
let injectedRequest = request;
|
|
2494
2566
|
if (request.method === 'tools/call' && typeof toolName === 'string') {
|
|
2567
|
+
if (toolName === 'seekMentoring' || toolName === 'get_fraim_job') {
|
|
2568
|
+
const sessionError = await this.checkLocalWorkflowSession(requestSessionId, request.id);
|
|
2569
|
+
if (sessionError)
|
|
2570
|
+
return sessionError;
|
|
2571
|
+
}
|
|
2495
2572
|
// 🔍 SMART DISPATCHER: Intercept mentoring and job/workflow tools for local overrides
|
|
2496
2573
|
if (toolName === 'seekMentoring') {
|
|
2497
2574
|
this.recordRecoveryState(args, requestSessionId);
|
|
@@ -2860,9 +2937,19 @@ class FraimLocalMCPServer {
|
|
|
2860
2937
|
if (firstRoot.uri && firstRoot.uri.startsWith('file://')) {
|
|
2861
2938
|
const rootPath = this.fileUriToLocalPath(firstRoot.uri);
|
|
2862
2939
|
if (rootPath) {
|
|
2940
|
+
const userEmail = this.engine?.getUserEmail();
|
|
2941
|
+
if (this.workspaceRoot !== rootPath) {
|
|
2942
|
+
// Both values retain project-specific identity and templates.
|
|
2943
|
+
// Rebuild them for the next queued request in the new root.
|
|
2944
|
+
this.repoInfo = null;
|
|
2945
|
+
this.engine = null;
|
|
2946
|
+
}
|
|
2863
2947
|
this.workspaceRoot = rootPath;
|
|
2864
2948
|
this.log(`✅ Got workspace root from client: ${this.workspaceRoot} (${firstRoot.name || 'unknown'})`);
|
|
2865
2949
|
this.loadConfig(); // Reload config with the correct workspace root
|
|
2950
|
+
// Workspace caches change; the authenticated session identity does not.
|
|
2951
|
+
if (userEmail && !this.engine)
|
|
2952
|
+
this.ensureEngine().setUserEmail(userEmail);
|
|
2866
2953
|
}
|
|
2867
2954
|
else {
|
|
2868
2955
|
this.log(`⚠️ Could not parse root URI: ${firstRoot.uri}`);
|
|
@@ -2879,6 +2966,7 @@ class FraimLocalMCPServer {
|
|
|
2879
2966
|
this.loadConfig();
|
|
2880
2967
|
}
|
|
2881
2968
|
}
|
|
2969
|
+
void this.ensureFreshLocalCatalogOnConnect('workspace-roots');
|
|
2882
2970
|
}
|
|
2883
2971
|
}
|
|
2884
2972
|
/**
|
|
@@ -2938,62 +3026,88 @@ class FraimLocalMCPServer {
|
|
|
2938
3026
|
clearInterval(uploadInterval);
|
|
2939
3027
|
this.shutdown();
|
|
2940
3028
|
};
|
|
2941
|
-
|
|
3029
|
+
// Stateful operations share one queue across input chunks. Discovery only
|
|
3030
|
+
// waits for initialization, so a remote tool call cannot delay local schema
|
|
3031
|
+
// negotiation. Root responses use the stateful queue to avoid changing a
|
|
3032
|
+
// request's workspace while that request is awaiting remote work.
|
|
3033
|
+
let statefulQueue = Promise.resolve();
|
|
3034
|
+
let initialized = Promise.resolve();
|
|
3035
|
+
const pending = new Set();
|
|
3036
|
+
const localMethods = new Set([
|
|
3037
|
+
'tools/list', 'resources/list', 'prompts/list', 'ping', 'notifications/initialized'
|
|
3038
|
+
]);
|
|
3039
|
+
const reportError = (error) => {
|
|
3040
|
+
this.logError(`Message processing failed: ${error.message}`);
|
|
3041
|
+
process.stdout.write(JSON.stringify({
|
|
3042
|
+
jsonrpc: '2.0', error: { code: -32700, message: `Parse error: ${error.message}` }, id: null
|
|
3043
|
+
}) + '\n');
|
|
3044
|
+
};
|
|
3045
|
+
const dispatch = async (message) => {
|
|
3046
|
+
// Distinguish between requests and responses
|
|
3047
|
+
// Requests have 'method', responses have 'result' or 'error' (but no 'method')
|
|
3048
|
+
if ('method' in message && message.method) {
|
|
3049
|
+
// This is a request from the client
|
|
3050
|
+
const response = await this.handleRequest(message);
|
|
3051
|
+
if (message.method === 'tools/call' && !this.firstToolCompletedLogged) {
|
|
3052
|
+
this.firstToolCompletedLogged = true;
|
|
3053
|
+
this.log(`First MCP tool call completed in ${(performance.now() - this.launchedAt).toFixed(0)} ms`);
|
|
3054
|
+
}
|
|
3055
|
+
// Only send response if we got one (null means we handled it internally)
|
|
3056
|
+
if (response) {
|
|
3057
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
3058
|
+
// Usage is optional; token capture and upload must not hold the
|
|
3059
|
+
// client response open after the tool result is ready.
|
|
3060
|
+
void this.collectUsageForResponse(message, response)
|
|
3061
|
+
.catch((error) => this.logError(`Usage collection failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
else if ('result' in message || 'error' in message) {
|
|
3065
|
+
// This is a response from the client (to our roots/list request)
|
|
3066
|
+
this.handleResponse(message);
|
|
3067
|
+
// Don't send anything back - this was a response to our request
|
|
3068
|
+
}
|
|
3069
|
+
else {
|
|
3070
|
+
this.logError(`Unknown message type: ${JSON.stringify(message)}`);
|
|
3071
|
+
process.stdout.write(JSON.stringify({
|
|
3072
|
+
jsonrpc: '2.0',
|
|
3073
|
+
error: {
|
|
3074
|
+
code: -32600,
|
|
3075
|
+
message: 'Invalid Request: Unknown message type'
|
|
3076
|
+
},
|
|
3077
|
+
id: message.id || null
|
|
3078
|
+
}) + '\n');
|
|
3079
|
+
}
|
|
3080
|
+
};
|
|
3081
|
+
process.stdin.on('data', (chunk) => {
|
|
2942
3082
|
buffer += chunk;
|
|
2943
|
-
// Process complete JSON-RPC messages (newline-delimited)
|
|
2944
3083
|
let newlineIndex;
|
|
2945
3084
|
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
2946
3085
|
const line = buffer.slice(0, newlineIndex).trim();
|
|
2947
3086
|
buffer = buffer.slice(newlineIndex + 1);
|
|
2948
|
-
if (line)
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
// This is a response from the client (to our roots/list request)
|
|
2965
|
-
this.handleResponse(message);
|
|
2966
|
-
// Don't send anything back - this was a response to our request
|
|
2967
|
-
}
|
|
2968
|
-
else {
|
|
2969
|
-
this.logError(`Unknown message type: ${JSON.stringify(message)}`);
|
|
2970
|
-
process.stdout.write(JSON.stringify({
|
|
2971
|
-
jsonrpc: '2.0',
|
|
2972
|
-
error: {
|
|
2973
|
-
code: -32600,
|
|
2974
|
-
message: 'Invalid Request: Unknown message type'
|
|
2975
|
-
},
|
|
2976
|
-
id: message.id || null
|
|
2977
|
-
}) + '\n');
|
|
2978
|
-
}
|
|
2979
|
-
}
|
|
2980
|
-
catch (error) {
|
|
2981
|
-
this.logError(`Message processing failed: ${error.message}`);
|
|
2982
|
-
// Send error response
|
|
2983
|
-
const errorResponse = {
|
|
2984
|
-
jsonrpc: '2.0',
|
|
2985
|
-
error: {
|
|
2986
|
-
code: -32700,
|
|
2987
|
-
message: `Parse error: ${error.message}`
|
|
2988
|
-
},
|
|
2989
|
-
id: null
|
|
2990
|
-
};
|
|
2991
|
-
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
|
2992
|
-
}
|
|
3087
|
+
if (!line)
|
|
3088
|
+
continue;
|
|
3089
|
+
try {
|
|
3090
|
+
const message = JSON.parse(line);
|
|
3091
|
+
const local = localMethods.has(message?.method);
|
|
3092
|
+
const operation = (local ? initialized : statefulQueue)
|
|
3093
|
+
.then(() => dispatch(message)).catch(reportError);
|
|
3094
|
+
if (!local)
|
|
3095
|
+
statefulQueue = operation;
|
|
3096
|
+
if (message?.method === 'initialize')
|
|
3097
|
+
initialized = operation;
|
|
3098
|
+
pending.add(operation);
|
|
3099
|
+
void operation.finally(() => pending.delete(operation));
|
|
3100
|
+
}
|
|
3101
|
+
catch (error) {
|
|
3102
|
+
reportError(error);
|
|
2993
3103
|
}
|
|
2994
3104
|
}
|
|
2995
3105
|
});
|
|
2996
|
-
process.stdin.on('end', () => {
|
|
3106
|
+
process.stdin.on('end', async () => {
|
|
3107
|
+
await Promise.all(pending);
|
|
3108
|
+
// stdout can be asynchronous when piped. Flush accepted responses before
|
|
3109
|
+
// process.exit so a large final result cannot be truncated on shutdown.
|
|
3110
|
+
await new Promise(resolve => process.stdout.write('', () => resolve()));
|
|
2997
3111
|
this.log('🛑 Stdin closed, shutting down...');
|
|
2998
3112
|
cleanup();
|
|
2999
3113
|
process.exit(0);
|
|
@@ -3055,13 +3169,14 @@ class FraimLocalMCPServer {
|
|
|
3055
3169
|
}
|
|
3056
3170
|
// Keep enrichment request-local until it settles. Neither another call
|
|
3057
3171
|
// nor the uploader may observe an event that is still being constructed.
|
|
3172
|
+
const usageContext = this.usageCollector.captureContext();
|
|
3058
3173
|
const enrichment = toolName === 'seekMentoring' ? await this.captureTokenUsage() : {};
|
|
3059
3174
|
if (this.isShutdown)
|
|
3060
3175
|
return;
|
|
3061
3176
|
// Capture the current queue size before synchronous publication.
|
|
3062
3177
|
const beforeCount = this.usageCollector.getEventCount();
|
|
3063
3178
|
try {
|
|
3064
|
-
this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success, enrichment);
|
|
3179
|
+
this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success, enrichment, usageContext);
|
|
3065
3180
|
// Check if the event was actually added to the queue
|
|
3066
3181
|
const afterCount = this.usageCollector.getEventCount();
|
|
3067
3182
|
if (afterCount > beforeCount) {
|
|
@@ -3077,7 +3192,7 @@ class FraimLocalMCPServer {
|
|
|
3077
3192
|
// Emit job-complete event when seekMentoring reaches the final phase
|
|
3078
3193
|
if (toolName === 'seekMentoring' && acceptedPhaseResponse && args.status === 'complete' && args.nextPhase === null && args.jobName && args.jobId) {
|
|
3079
3194
|
try {
|
|
3080
|
-
this.usageCollector.collectJobComplete(args.jobName, args.jobId, requestSessionId);
|
|
3195
|
+
this.usageCollector.collectJobComplete(args.jobName, args.jobId, requestSessionId, usageContext);
|
|
3081
3196
|
this.log(`📊 ✅ Job complete event emitted: ${args.jobName} (jobId: ${args.jobId})`);
|
|
3082
3197
|
}
|
|
3083
3198
|
catch (err) {
|
|
@@ -54,10 +54,19 @@ class UsageCollector {
|
|
|
54
54
|
}
|
|
55
55
|
getAgentName() { return this.agentName; }
|
|
56
56
|
getAgentModel() { return this.agentModel; }
|
|
57
|
+
/** Freeze attribution before asynchronous enrichment can overlap another connection. */
|
|
58
|
+
captureContext() {
|
|
59
|
+
return {
|
|
60
|
+
userId: this.userId || 'unknown',
|
|
61
|
+
repoIdentifier: this.repoIdentifier || undefined,
|
|
62
|
+
agentName: this.agentName || undefined,
|
|
63
|
+
agentModel: this.agentModel || undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
57
66
|
/**
|
|
58
67
|
* Collect MCP tool call event
|
|
59
68
|
*/
|
|
60
|
-
collectMCPCall(toolName, args, sessionId, success = true, enrichment = {}) {
|
|
69
|
+
collectMCPCall(toolName, args, sessionId, success = true, enrichment = {}, context = this.captureContext()) {
|
|
61
70
|
const parsed = this.parseMCPCall(toolName, args);
|
|
62
71
|
if (!parsed) {
|
|
63
72
|
const errorMsg = `[UsageCollector] 🚫 Tool not tracked: ${toolName}`;
|
|
@@ -75,14 +84,14 @@ class UsageCollector {
|
|
|
75
84
|
const event = {
|
|
76
85
|
type: parsed.type,
|
|
77
86
|
name: parsed.name,
|
|
78
|
-
//
|
|
79
|
-
userId:
|
|
87
|
+
// The server overrides this with the authenticated userId.
|
|
88
|
+
userId: context.userId,
|
|
80
89
|
sessionId,
|
|
81
90
|
success,
|
|
82
91
|
category: parsed.category,
|
|
83
|
-
repoIdentifier:
|
|
84
|
-
agentName:
|
|
85
|
-
agentModel:
|
|
92
|
+
repoIdentifier: context.repoIdentifier,
|
|
93
|
+
agentName: context.agentName,
|
|
94
|
+
agentModel: context.agentModel,
|
|
86
95
|
tokenSnapshot: enrichment.tokenSnapshot,
|
|
87
96
|
tokenCaptureUnavailableReason: enrichment.tokenCaptureUnavailableReason,
|
|
88
97
|
jobId: args.jobId || undefined, // Bug fix: capture jobId from enriched args
|
|
@@ -190,17 +199,17 @@ class UsageCollector {
|
|
|
190
199
|
* Emit a job-completion event when seekMentoring returns nextPhase === null.
|
|
191
200
|
* Called by the local proxy after the final mentoring event is queued.
|
|
192
201
|
*/
|
|
193
|
-
collectJobComplete(jobName, jobId, sessionId) {
|
|
202
|
+
collectJobComplete(jobName, jobId, sessionId, context = this.captureContext()) {
|
|
194
203
|
const event = {
|
|
195
204
|
type: 'job',
|
|
196
205
|
name: jobName,
|
|
197
|
-
userId:
|
|
206
|
+
userId: context.userId,
|
|
198
207
|
sessionId,
|
|
199
208
|
success: true,
|
|
200
209
|
jobId,
|
|
201
|
-
repoIdentifier:
|
|
202
|
-
agentName:
|
|
203
|
-
agentModel:
|
|
210
|
+
repoIdentifier: context.repoIdentifier,
|
|
211
|
+
agentName: context.agentName,
|
|
212
|
+
agentModel: context.agentModel,
|
|
204
213
|
args: { action: 'complete', jobId },
|
|
205
214
|
};
|
|
206
215
|
this.events.push(event);
|
|
@@ -4,6 +4,7 @@ exports.AuthMiddleware = void 0;
|
|
|
4
4
|
const redact_secrets_1 = require("../core/utils/redact-secrets");
|
|
5
5
|
const fs_1 = require("fs");
|
|
6
6
|
const path_1 = require("path");
|
|
7
|
+
const current_license_1 = require("../fraim/current-license");
|
|
7
8
|
const cookie_service_1 = require("../services/cookie-service");
|
|
8
9
|
const audit_log_1 = require("../services/audit-log");
|
|
9
10
|
function resolveFraimProFilePath(fileName) {
|
|
@@ -58,6 +59,41 @@ class AuthMiddleware {
|
|
|
58
59
|
if (isPublic) {
|
|
59
60
|
return next();
|
|
60
61
|
}
|
|
62
|
+
// Identity remains available to explain and recover an expired subscription.
|
|
63
|
+
// These exact read endpoints do not execute product work.
|
|
64
|
+
const recoveryRead = req.method === 'GET' && [
|
|
65
|
+
'/account', '/billing', '/api/auth/session', '/api/account/me',
|
|
66
|
+
'/api/account/api-key', '/api/account/sessions', '/api/account/activity',
|
|
67
|
+
'/api/account/referral-code', '/api/analytics/me', '/api/personas/me'
|
|
68
|
+
].includes(p);
|
|
69
|
+
const enforceLicense = (key) => {
|
|
70
|
+
if ((recoveryRead && !key.revokedAt) || (0, current_license_1.hasUsableLicense)(key))
|
|
71
|
+
return true;
|
|
72
|
+
const suspended = key.status === 'suspended';
|
|
73
|
+
const reason = key.suspensionReason || 'payment failure';
|
|
74
|
+
console.error(`[FRAIM AUTH] ${suspended ? 'Suspended' : 'Expired'} API key: ${(0, redact_secrets_1.redactApiKey)(key.key)} (${key.tier})${suspended ? ` due to ${reason}` : ''}`);
|
|
75
|
+
res.status(402).json({
|
|
76
|
+
jsonrpc: '2.0',
|
|
77
|
+
error: {
|
|
78
|
+
code: suspended ? -32002 : -32003,
|
|
79
|
+
message: suspended
|
|
80
|
+
? `API key suspended due to ${reason}. Please update your payment method.`
|
|
81
|
+
: key.tier === 'trial'
|
|
82
|
+
? 'Your 14-day free trial has expired. Please upgrade to continue using FRAIM.'
|
|
83
|
+
: 'Your subscription has expired. Please renew to continue using FRAIM.',
|
|
84
|
+
data: {
|
|
85
|
+
status: suspended ? 'suspended' : 'expired', tier: key.tier,
|
|
86
|
+
...(suspended ? { reason } : {}),
|
|
87
|
+
expiresAt: key.expiresAt?.toISOString(),
|
|
88
|
+
paymentUrl: key.tier === 'trial'
|
|
89
|
+
? (process.env.STRIPE_CHECKOUT_URL || 'https://fraimworks.ai/pricing')
|
|
90
|
+
: (process.env.STRIPE_BILLING_PORTAL_URL || 'https://fraimworks.ai/billing')
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
id: req.body?.id || null
|
|
94
|
+
});
|
|
95
|
+
return false;
|
|
96
|
+
};
|
|
61
97
|
// Header-only API key transport. Query-string transport was removed because credentials
|
|
62
98
|
// in URLs leak through access logs, browser history, and HTTP Referer headers.
|
|
63
99
|
// Reject any request that attempts to use ?api-key= so silent fallback to header doesn't mask the misuse.
|
|
@@ -90,17 +126,16 @@ class AuthMiddleware {
|
|
|
90
126
|
if (!apiKey && sessionToken) {
|
|
91
127
|
try {
|
|
92
128
|
const session = await this.dbService.getAuthSession(sessionToken);
|
|
93
|
-
if (!session || session.revoked || session.expiresAt
|
|
129
|
+
if (!session || session.revoked || session.expiresAt.getTime() <= Date.now()) {
|
|
94
130
|
// MCP clients (claude.ai Connectors, Office add-ins, Claude Design) send the
|
|
95
131
|
// FRAIM API key as Authorization: Bearer <key>. The bearer value arrives here
|
|
96
132
|
// as sessionToken but is not a session UUID — it's the raw key. Try it as a
|
|
97
133
|
// direct API key before returning 401.
|
|
98
134
|
if (sessionFromBearer) {
|
|
99
135
|
const directKeyData = await this.dbService.verifyApiKey(sessionFromBearer).catch(() => null);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (isDirectKeyValid) {
|
|
136
|
+
if (directKeyData) {
|
|
137
|
+
if (!enforceLicense(directKeyData))
|
|
138
|
+
return;
|
|
104
139
|
this.dbService.updateApiKeyLastUsed(directKeyData.key).catch(() => undefined);
|
|
105
140
|
req.apiKeyData = directKeyData;
|
|
106
141
|
req.authSource = 'bearer-api-key';
|
|
@@ -133,6 +168,8 @@ class AuthMiddleware {
|
|
|
133
168
|
id: req.body?.id || null
|
|
134
169
|
});
|
|
135
170
|
}
|
|
171
|
+
if (!enforceLicense(apiKeyData))
|
|
172
|
+
return;
|
|
136
173
|
this.dbService.touchAuthSession(session.sessionId).catch(err => {
|
|
137
174
|
console.warn('[FRAIM AUTH] Failed to update auth session lastSeenAt:', err);
|
|
138
175
|
});
|
|
@@ -228,52 +265,8 @@ class AuthMiddleware {
|
|
|
228
265
|
id: req.body?.id || null
|
|
229
266
|
});
|
|
230
267
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const reason = apiKeyData.suspensionReason || 'payment failure';
|
|
234
|
-
console.error(`❌ FRAIM AUTH: Suspended API key: ${(0, redact_secrets_1.redactApiKey)(apiKey)} (reason: ${reason})`);
|
|
235
|
-
return res.status(402).json({
|
|
236
|
-
jsonrpc: '2.0',
|
|
237
|
-
error: {
|
|
238
|
-
code: -32002,
|
|
239
|
-
message: `API key suspended due to ${reason}. Please update your payment method.`,
|
|
240
|
-
data: {
|
|
241
|
-
status: 'suspended',
|
|
242
|
-
reason,
|
|
243
|
-
paymentUrl: process.env.STRIPE_BILLING_PORTAL_URL || 'https://fraimworks.ai/billing'
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
id: req.body?.id || null
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
// Check if key is expired
|
|
250
|
-
if (apiKeyData.status === 'expired' || (apiKeyData.expiresAt && new Date() > apiKeyData.expiresAt)) {
|
|
251
|
-
// Update status to expired if not already
|
|
252
|
-
if (apiKeyData.status !== 'expired') {
|
|
253
|
-
await this.dbService.updateApiKey(apiKey, { status: 'expired' });
|
|
254
|
-
}
|
|
255
|
-
const tier = apiKeyData.tier;
|
|
256
|
-
const message = tier === 'trial'
|
|
257
|
-
? 'Your 14-day free trial has expired. Please upgrade to continue using FRAIM.'
|
|
258
|
-
: 'Your subscription has expired. Please renew to continue using FRAIM.';
|
|
259
|
-
console.error(`❌ FRAIM AUTH: Expired API key: ${(0, redact_secrets_1.redactApiKey)(apiKey)} (tier: ${tier})`);
|
|
260
|
-
return res.status(402).json({
|
|
261
|
-
jsonrpc: '2.0',
|
|
262
|
-
error: {
|
|
263
|
-
code: -32003,
|
|
264
|
-
message,
|
|
265
|
-
data: {
|
|
266
|
-
status: 'expired',
|
|
267
|
-
tier,
|
|
268
|
-
expiresAt: apiKeyData.expiresAt?.toISOString(),
|
|
269
|
-
paymentUrl: tier === 'trial'
|
|
270
|
-
? (process.env.STRIPE_CHECKOUT_URL || 'https://fraimworks.ai/pricing')
|
|
271
|
-
: (process.env.STRIPE_BILLING_PORTAL_URL || 'https://fraimworks.ai/billing')
|
|
272
|
-
}
|
|
273
|
-
},
|
|
274
|
-
id: req.body?.id || null
|
|
275
|
-
});
|
|
276
|
-
}
|
|
268
|
+
if (!enforceLicense(apiKeyData))
|
|
269
|
+
return;
|
|
277
270
|
// Update last used timestamp (async, don't await)
|
|
278
271
|
this.dbService.updateApiKeyLastUsed(apiKey).catch(err => {
|
|
279
272
|
console.warn('[FRAIM AUTH] Failed to update lastUsedAt:', err);
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const license_presentation_1 = require("../fraim/license-presentation");
|
|
6
7
|
const express_1 = __importDefault(require("express"));
|
|
7
8
|
const path_1 = __importDefault(require("path"));
|
|
8
9
|
const usage_analytics_service_js_1 = require("../services/usage-analytics-service.js");
|
|
@@ -884,7 +885,7 @@ router.get('/me', async (req, res) => {
|
|
|
884
885
|
res.json({
|
|
885
886
|
userId: apiKeyData.userId,
|
|
886
887
|
isAdmin: apiKeyData.userId === 'sid.mathur@gmail.com',
|
|
887
|
-
...
|
|
888
|
+
...(0, license_presentation_1.serializeLicenseLifecycle)(apiKeyData),
|
|
888
889
|
});
|
|
889
890
|
}
|
|
890
891
|
catch (error) {
|
|
@@ -927,15 +928,6 @@ function resolveAnalyticsTimeframe(query) {
|
|
|
927
928
|
}
|
|
928
929
|
return startDate && endDate ? { period: 'custom', startDate, endDate } : { period };
|
|
929
930
|
}
|
|
930
|
-
function serializeKeyLifecycle(keyData) {
|
|
931
|
-
if (!keyData) {
|
|
932
|
-
return { status: null, expiresAt: null };
|
|
933
|
-
}
|
|
934
|
-
return {
|
|
935
|
-
status: keyData.status ?? 'active',
|
|
936
|
-
expiresAt: keyData.expiresAt instanceof Date ? keyData.expiresAt.toISOString() : null,
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
931
|
async function resolveManagerMember(dbService, managerId, memberId) {
|
|
940
932
|
const members = await dbService.getTeamMembers(managerId);
|
|
941
933
|
if (!members.find(m => m.memberId === memberId)) {
|
|
@@ -1029,7 +1021,7 @@ router.get('/team/members/:memberId/summary', async (req, res) => {
|
|
|
1029
1021
|
const lastEventAt = stats.status === 'fulfilled' ? stats.value.lastEventAt ?? null : null;
|
|
1030
1022
|
const historicalLastEventAt = allTimeLastEventAt.status === 'fulfilled' ? allTimeLastEventAt.value ?? null : null;
|
|
1031
1023
|
const lifecycle = keyLifecycle.status === 'fulfilled'
|
|
1032
|
-
?
|
|
1024
|
+
? (0, license_presentation_1.serializeLicenseLifecycle)(keyLifecycle.value)
|
|
1033
1025
|
: { status: null, expiresAt: null };
|
|
1034
1026
|
res.json({
|
|
1035
1027
|
memberId,
|