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.
@@ -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
- const canRetryTransientRemoteReset = request.method === 'initialize' ||
2349
- request.method === 'tools/list' ||
2350
- request.method === 'resources/list' ||
2351
- request.method === 'prompts/list' ||
2352
- (request.method === 'tools/call' && request.params?.name === 'fraim_connect');
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
- ...headers,
2412
- Connection: 'close'
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
- // Proxy initialize to remote server first using the raw proxy method
2475
- const response = await this._doProxyToRemote(request, requestId);
2476
- const processedResponse = await this.finalizeToolResponse(request, response, requestSessionId, requestId);
2477
- // After successful initialization, load config
2478
- if (!processedResponse.error) {
2479
- // Load config immediately for compatibility, then request roots so
2480
- // workspace-aware clients (Codex, Cursor, etc.) can provide exact roots.
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
- this.log(`📤 ${request.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
2487
- return processedResponse;
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
- process.stdin.on('data', async (chunk) => {
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
- try {
2950
- const message = JSON.parse(line);
2951
- // Distinguish between requests and responses
2952
- // Requests have 'method', responses have 'result' or 'error' (but no 'method')
2953
- if ('method' in message && message.method) {
2954
- // This is a request from the client
2955
- const response = await this.handleRequest(message);
2956
- // Only send response if we got one (null means we handled it internally)
2957
- if (response) {
2958
- // Collect usage for all tools/call requests before sending response
2959
- await this.collectUsageForResponse(message, response);
2960
- process.stdout.write(JSON.stringify(response) + '\n');
2961
- }
2962
- }
2963
- else if ('result' in message || 'error' in message) {
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
- // Use set userId if available; the server will override with the authenticated userId.
79
- userId: this.userId || 'unknown',
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: this.repoIdentifier || undefined,
84
- agentName: this.agentName || undefined,
85
- agentModel: this.agentModel || undefined,
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: this.userId || 'unknown',
206
+ userId: context.userId,
198
207
  sessionId,
199
208
  success: true,
200
209
  jobId,
201
- repoIdentifier: this.repoIdentifier || undefined,
202
- agentName: this.agentName || undefined,
203
- agentModel: this.agentModel || undefined,
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 < new Date()) {
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
- const isDirectKeyValid = directKeyData
101
- && directKeyData.status !== 'suspended'
102
- && !(directKeyData.status === 'expired' || (directKeyData.expiresAt && new Date() > directKeyData.expiresAt));
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
- // Check if key is suspended
232
- if (apiKeyData.status === 'suspended') {
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
- ...serializeKeyLifecycle(apiKeyData),
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
- ? serializeKeyLifecycle(keyLifecycle.value)
1024
+ ? (0, license_presentation_1.serializeLicenseLifecycle)(keyLifecycle.value)
1033
1025
  : { status: null, expiresAt: null };
1034
1026
  res.json({
1035
1027
  memberId,