fraim-framework 2.0.179 → 2.0.180

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.
Files changed (59) hide show
  1. package/dist/src/ai-hub/desktop-main.js +2 -2
  2. package/dist/src/api/admin/payments.js +33 -0
  3. package/dist/src/api/admin/sales-leads.js +21 -0
  4. package/dist/src/api/payment/create-session.js +338 -0
  5. package/dist/src/api/payment/dashboard-link.js +149 -0
  6. package/dist/src/api/payment/session-details.js +31 -0
  7. package/dist/src/api/payment/webhook.js +587 -0
  8. package/dist/src/api/personas/me.js +29 -0
  9. package/dist/src/api/pricing/get-config.js +25 -0
  10. package/dist/src/api/sales/contact.js +44 -0
  11. package/dist/src/cli/distribution/marketplace-bundles.js +5 -1
  12. package/dist/src/db/payment-repository.js +61 -0
  13. package/dist/src/fraim/config-loader.js +11 -0
  14. package/dist/src/fraim/db-service.js +2387 -0
  15. package/dist/src/fraim/issues.js +152 -0
  16. package/dist/src/fraim/template-processor.js +184 -0
  17. package/dist/src/fraim/utils/request-utils.js +23 -0
  18. package/dist/src/middleware/auth.js +266 -0
  19. package/dist/src/middleware/cors-config.js +111 -0
  20. package/dist/src/middleware/logger.js +116 -0
  21. package/dist/src/middleware/rate-limit.js +110 -0
  22. package/dist/src/middleware/reject-query-api-key.js +45 -0
  23. package/dist/src/middleware/security-headers.js +41 -0
  24. package/dist/src/middleware/telemetry.js +134 -0
  25. package/dist/src/models/payment.js +2 -0
  26. package/dist/src/routes/analytics.js +1447 -0
  27. package/dist/src/routes/app-routes.js +32 -0
  28. package/dist/src/routes/auth-routes.js +505 -0
  29. package/dist/src/routes/oauth-routes.js +325 -0
  30. package/dist/src/routes/payment-routes.js +186 -0
  31. package/dist/src/routes/persona-catalog-routes.js +84 -0
  32. package/dist/src/services/admin-service.js +229 -0
  33. package/dist/src/services/audit-log-persistence.js +60 -0
  34. package/dist/src/services/audit-log.js +69 -0
  35. package/dist/src/services/cookie-service.js +129 -0
  36. package/dist/src/services/dashboard-access.js +27 -0
  37. package/dist/src/services/demo-seed-service.js +139 -0
  38. package/dist/src/services/email-code.js +23 -0
  39. package/dist/src/services/email-service-clean.js +782 -0
  40. package/dist/src/services/email-service.js +951 -0
  41. package/dist/src/services/installer-service.js +131 -0
  42. package/dist/src/services/mcp-oauth-store.js +33 -0
  43. package/dist/src/services/mcp-service.js +823 -0
  44. package/dist/src/services/oauth-helpers.js +127 -0
  45. package/dist/src/services/org-service.js +89 -0
  46. package/dist/src/services/persona-entitlement-service.js +288 -0
  47. package/dist/src/services/provider-service.js +215 -0
  48. package/dist/src/services/registry-service.js +628 -0
  49. package/dist/src/services/session-service.js +86 -0
  50. package/dist/src/services/trial-reminder-service.js +120 -0
  51. package/dist/src/services/usage-analytics-service.js +419 -0
  52. package/dist/src/services/workspace-identity.js +21 -0
  53. package/dist/src/types/analytics.js +2 -0
  54. package/dist/src/utils/payment-calculator.js +52 -0
  55. package/extensions/office-word/favicon.ico +0 -0
  56. package/extensions/office-word/icon-64.png +0 -0
  57. package/extensions/office-word/manifest.xml +33 -0
  58. package/extensions/office-word/taskpane.html +242 -0
  59. package/package.json +12 -2
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.fileFraimIssue = fileFraimIssue;
7
+ exports.listFraimIssuesByReporter = listFraimIssuesByReporter;
8
+ const axios_1 = __importDefault(require("axios"));
9
+ const FRAIM_REPO_OWNER = 'mathursrus';
10
+ const FRAIM_REPO_NAME = 'FRAIM';
11
+ const DEFAULT_LIST_LIMIT = 20;
12
+ const MAX_LIST_LIMIT = 100;
13
+ function normalizeLimit(limit) {
14
+ if (!Number.isFinite(limit))
15
+ return DEFAULT_LIST_LIMIT;
16
+ const parsed = Math.trunc(limit);
17
+ if (parsed <= 0)
18
+ return DEFAULT_LIST_LIMIT;
19
+ return Math.min(parsed, MAX_LIST_LIMIT);
20
+ }
21
+ /**
22
+ * File an issue in the FRAIM repository
23
+ *
24
+ * This function creates issues in the FRAIM repository for bug reports,
25
+ * feature requests, and workflow contributions.
26
+ */
27
+ async function fileFraimIssue(params) {
28
+ const { title, labels, dryRun, reporterEmail } = params;
29
+ // Prepend reporter line so the close-the-loop job can identify who filed the issue
30
+ const reporterLine = reporterEmail ? `**Reported by:** ${reporterEmail}\n\n` : '';
31
+ const body = reporterLine + params.body;
32
+ // Always target the FRAIM repository
33
+ const owner = FRAIM_REPO_OWNER;
34
+ const repo = FRAIM_REPO_NAME;
35
+ if (dryRun) {
36
+ return {
37
+ success: true,
38
+ dryRun: true,
39
+ message: `[DRY RUN] Would create GitHub issue: "${title}" in ${owner}/${repo}.`
40
+ };
41
+ }
42
+ try {
43
+ // Check for GitHub token
44
+ const token = process.env.GITHUB_TOKEN;
45
+ if (!token) {
46
+ return {
47
+ success: false,
48
+ message: `GitHub integration requires GITHUB_TOKEN environment variable.
49
+
50
+ Please set GITHUB_TOKEN environment variable to file issues in ${owner}/${repo}.`
51
+ };
52
+ }
53
+ // Create the issue in FRAIM repository
54
+ const url = `https://api.github.com/repos/${owner}/${repo}/issues`;
55
+ const payload = { title, body };
56
+ if (labels && labels.length > 0) {
57
+ payload.labels = labels;
58
+ }
59
+ const response = await axios_1.default.post(url, payload, {
60
+ headers: {
61
+ 'Authorization': `Bearer ${token}`,
62
+ 'Accept': 'application/vnd.github.v3+json',
63
+ 'Content-Type': 'application/json',
64
+ },
65
+ });
66
+ return {
67
+ success: true,
68
+ issueNumber: response.data.number,
69
+ htmlUrl: response.data.html_url
70
+ };
71
+ }
72
+ catch (error) {
73
+ let errorMessage = 'Unknown error';
74
+ if (axios_1.default.isAxiosError(error)) {
75
+ errorMessage = `Status: ${error.response?.status} - ${JSON.stringify(error.response?.data)}`;
76
+ }
77
+ else if (error instanceof Error) {
78
+ errorMessage = error.message;
79
+ }
80
+ return {
81
+ success: false,
82
+ message: `Failed to create issue in FRAIM repository: ${errorMessage}`
83
+ };
84
+ }
85
+ }
86
+ /**
87
+ * List issues filed into the FRAIM repository by a specific reporter email.
88
+ *
89
+ * Uses GitHub search against the existing "Reported by:" marker in issue bodies
90
+ * so we do not need a second FRAIM-owned index.
91
+ */
92
+ async function listFraimIssuesByReporter(params) {
93
+ const { reporterEmail } = params;
94
+ const limit = normalizeLimit(params.limit);
95
+ if (!reporterEmail) {
96
+ return {
97
+ success: false,
98
+ message: 'Reporter email is required to list FRAIM GitHub issues.'
99
+ };
100
+ }
101
+ const token = process.env.GITHUB_TOKEN;
102
+ if (!token) {
103
+ return {
104
+ success: false,
105
+ message: `GitHub integration requires GITHUB_TOKEN environment variable.
106
+
107
+ Please set GITHUB_TOKEN environment variable to query issues in ${FRAIM_REPO_OWNER}/${FRAIM_REPO_NAME}.`
108
+ };
109
+ }
110
+ const query = `repo:${FRAIM_REPO_OWNER}/${FRAIM_REPO_NAME} is:issue "Reported by: ${reporterEmail}" in:body`;
111
+ try {
112
+ const response = await axios_1.default.get('https://api.github.com/search/issues', {
113
+ headers: {
114
+ 'Authorization': `Bearer ${token}`,
115
+ 'Accept': 'application/vnd.github+json',
116
+ 'X-GitHub-Api-Version': '2022-11-28'
117
+ },
118
+ params: {
119
+ q: query,
120
+ sort: 'created',
121
+ order: 'desc',
122
+ per_page: limit,
123
+ page: 1
124
+ }
125
+ });
126
+ const issues = Array.isArray(response.data?.items)
127
+ ? response.data.items.map((item) => ({
128
+ issueNumber: item.number,
129
+ title: item.title,
130
+ status: item.state === 'closed' ? 'closed' : 'open',
131
+ createdAt: item.created_at
132
+ }))
133
+ : [];
134
+ return {
135
+ success: true,
136
+ issues
137
+ };
138
+ }
139
+ catch (error) {
140
+ let errorMessage = 'Unknown error';
141
+ if (axios_1.default.isAxiosError(error)) {
142
+ errorMessage = `Status: ${error.response?.status} - ${JSON.stringify(error.response?.data)}`;
143
+ }
144
+ else if (error instanceof Error) {
145
+ errorMessage = error.message;
146
+ }
147
+ return {
148
+ success: false,
149
+ message: `Failed to query issues in the FRAIM repository: ${errorMessage}`
150
+ };
151
+ }
152
+ }
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TemplateEngine = void 0;
4
+ exports.getTemplateEngine = getTemplateEngine;
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ const provider_utils_1 = require("../core/utils/provider-utils");
8
+ const object_utils_1 = require("../core/utils/object-utils");
9
+ /**
10
+ * Multi-Provider Template Engine
11
+ * Processes workflow templates with provider-specific action mappings
12
+ */
13
+ class TemplateEngine {
14
+ constructor(registryPath) {
15
+ this.providers = new Map();
16
+ this.registryPath = registryPath || (0, path_1.join)(__dirname, '../../registry');
17
+ this.loadProviders();
18
+ }
19
+ loadProviders() {
20
+ try {
21
+ // Load GitHub provider templates
22
+ const githubPath = (0, path_1.join)(this.registryPath, 'providers/github.json');
23
+ if ((0, fs_1.existsSync)(githubPath)) {
24
+ const githubTemplates = JSON.parse((0, fs_1.readFileSync)(githubPath, 'utf-8'));
25
+ this.providers.set('github', githubTemplates);
26
+ }
27
+ // Load ADO provider templates
28
+ const adoPath = (0, path_1.join)(this.registryPath, 'providers/ado.json');
29
+ if ((0, fs_1.existsSync)(adoPath)) {
30
+ const adoTemplates = JSON.parse((0, fs_1.readFileSync)(adoPath, 'utf-8'));
31
+ this.providers.set('ado', adoTemplates);
32
+ }
33
+ // Load Jira provider templates
34
+ const jiraPath = (0, path_1.join)(this.registryPath, 'providers/jira.json');
35
+ if ((0, fs_1.existsSync)(jiraPath)) {
36
+ const jiraTemplates = JSON.parse((0, fs_1.readFileSync)(jiraPath, 'utf-8'));
37
+ this.providers.set('jira', jiraTemplates);
38
+ }
39
+ // Load GitLab provider templates
40
+ const gitlabPath = (0, path_1.join)(this.registryPath, 'providers/gitlab.json');
41
+ if ((0, fs_1.existsSync)(gitlabPath)) {
42
+ const gitlabTemplates = JSON.parse((0, fs_1.readFileSync)(gitlabPath, 'utf-8'));
43
+ this.providers.set('gitlab', gitlabTemplates);
44
+ }
45
+ console.log(`✅ Loaded ${this.providers.size} provider templates`);
46
+ }
47
+ catch (error) {
48
+ console.warn('⚠️ Failed to load provider templates:', error);
49
+ }
50
+ }
51
+ /**
52
+ * Process workflow content with provider actions only
53
+ */
54
+ processWorkflow(workflowContent, repositoryInfo) {
55
+ // Only process provider actions, no client-side config variables
56
+ return this.processProviderActions(workflowContent, repositoryInfo);
57
+ }
58
+ processProviderActions(workflowContent, repositoryInfo) {
59
+ // Determine code provider from repository info using shared utility
60
+ const repoData = repositoryInfo?.repository || repositoryInfo;
61
+ const codeProvider = repoData?.provider || (0, provider_utils_1.detectProvider)(repoData?.url);
62
+ // Determine issue provider from explicit issueTracking context only
63
+ const issueProvider = repositoryInfo?.issueTracking?.provider;
64
+ // Get templates for both providers
65
+ const codeTemplates = this.providers.get(codeProvider);
66
+ const issueTemplates = issueProvider ? this.providers.get(issueProvider) : undefined;
67
+ if (!codeTemplates && !issueTemplates) {
68
+ console.warn(`⚠️ No templates found for providers: code=${codeProvider}, issue=${issueProvider}`);
69
+ return workflowContent;
70
+ }
71
+ // Process with both providers
72
+ return this.processWithSplitProviders(workflowContent, codeProvider, issueProvider, repositoryInfo);
73
+ }
74
+ processWithSplitProviders(workflowContent, codeProvider, issueProvider, repositoryInfo) {
75
+ const codeTemplates = this.providers.get(codeProvider);
76
+ const issueTemplates = issueProvider ? this.providers.get(issueProvider) : undefined;
77
+ let processed = workflowContent;
78
+ // Define which actions are code-related vs issue-related
79
+ const issueActions = new Set([
80
+ 'get_issue', 'update_issue_status', 'add_issue_comment',
81
+ 'create_issue', 'assign_issue', 'search_issues',
82
+ 'close_issue', 'list_issues'
83
+ ]);
84
+ const codeActions = new Set([
85
+ 'create_pr', 'get_pr', 'get_pr_comments', 'get_pr_review_comments',
86
+ 'get_pr_reviews', 'add_pr_comment', 'merge_pr', 'create_branch',
87
+ 'switch_branch', 'commit_changes', 'push_branch'
88
+ ]);
89
+ // Replace issue actions with issue provider templates
90
+ if (issueTemplates) {
91
+ for (const [action, template] of Object.entries(issueTemplates)) {
92
+ if (issueActions.has(action)) {
93
+ const regex = new RegExp(`{{${action}}}`, 'g');
94
+ const renderedTemplate = this.renderTemplate(template, repositoryInfo);
95
+ processed = processed.replace(regex, renderedTemplate);
96
+ }
97
+ }
98
+ }
99
+ // Replace code actions with code provider templates
100
+ if (codeTemplates) {
101
+ for (const [action, template] of Object.entries(codeTemplates)) {
102
+ if (codeActions.has(action)) {
103
+ const regex = new RegExp(`{{${action}}}`, 'g');
104
+ const renderedTemplate = this.renderTemplate(template, repositoryInfo);
105
+ processed = processed.replace(regex, renderedTemplate);
106
+ }
107
+ }
108
+ }
109
+ return processed;
110
+ }
111
+ /**
112
+ * Render template with repository variable substitution
113
+ */
114
+ renderTemplate(template, repositoryInfo) {
115
+ if (!repositoryInfo)
116
+ return template;
117
+ return template.replace(/{{([^}]+)}}/g, (match, path) => {
118
+ const trimmedPath = path.trim();
119
+ // Handle proxy.repository.* variables
120
+ if (trimmedPath.startsWith('proxy.repository.')) {
121
+ const repoPath = trimmedPath.substring('proxy.repository.'.length);
122
+ let repoData = repositoryInfo.repository || repositoryInfo;
123
+ const value = (0, object_utils_1.getNestedValue)(repoData, repoPath);
124
+ return value !== undefined ? String(value) : match;
125
+ }
126
+ // Handle proxy.issueTracking.* variables (for split provider mode)
127
+ if (trimmedPath.startsWith('proxy.issueTracking.')) {
128
+ const issueTrackingPath = trimmedPath.substring('proxy.issueTracking.'.length);
129
+ const issueTrackingData = repositoryInfo.issueTracking;
130
+ if (issueTrackingData) {
131
+ const value = (0, object_utils_1.getNestedValue)(issueTrackingData, issueTrackingPath);
132
+ return value !== undefined ? String(value) : match;
133
+ }
134
+ return match;
135
+ }
136
+ return match;
137
+ });
138
+ }
139
+ /**
140
+ * Get available providers
141
+ */
142
+ getAvailableProviders() {
143
+ return Array.from(this.providers.keys());
144
+ }
145
+ /**
146
+ * Get available actions for a provider
147
+ */
148
+ getProviderActions(provider) {
149
+ const templates = this.providers.get(provider);
150
+ return templates ? Object.keys(templates) : [];
151
+ }
152
+ /**
153
+ * Validate that all template actions in content are supported
154
+ */
155
+ validateWorkflow(workflowContent, provider) {
156
+ const actionRegex = /{{(\w+)}}/g;
157
+ const actions = new Set();
158
+ let match;
159
+ while ((match = actionRegex.exec(workflowContent)) !== null) {
160
+ // Skip config variables
161
+ if (!match[1].startsWith('config.')) {
162
+ actions.add(match[1]);
163
+ }
164
+ }
165
+ const providerActions = this.getProviderActions(provider);
166
+ const missingActions = Array.from(actions).filter(action => !providerActions.includes(action));
167
+ return {
168
+ valid: missingActions.length === 0,
169
+ missingActions
170
+ };
171
+ }
172
+ }
173
+ exports.TemplateEngine = TemplateEngine;
174
+ // Global template engine instance
175
+ let globalTemplateEngine = null;
176
+ /**
177
+ * Get or create global template engine instance
178
+ */
179
+ function getTemplateEngine(registryPath) {
180
+ if (!globalTemplateEngine || (registryPath && registryPath !== globalTemplateEngine['registryPath'])) {
181
+ globalTemplateEngine = new TemplateEngine(registryPath);
182
+ }
183
+ return globalTemplateEngine;
184
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EMAIL_REGEX = void 0;
4
+ exports.validateEmail = validateEmail;
5
+ exports.getRequestMeta = getRequestMeta;
6
+ /**
7
+ * Standard email validation regex
8
+ */
9
+ exports.EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10
+ /**
11
+ * Validate an email address
12
+ */
13
+ function validateEmail(email) {
14
+ return exports.EMAIL_REGEX.test(email);
15
+ }
16
+ /**
17
+ * Extract IP and UserAgent from an Express Request
18
+ */
19
+ function getRequestMeta(req) {
20
+ const ip = req.headers['x-forwarded-for'] || req.ip || 'unknown';
21
+ const userAgent = req.headers['user-agent'] || 'unknown';
22
+ return { ip, userAgent };
23
+ }
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AuthMiddleware = void 0;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ const cookie_service_1 = require("../services/cookie-service");
7
+ const audit_log_1 = require("../services/audit-log");
8
+ function resolveFraimProFilePath(fileName) {
9
+ const candidates = [
10
+ (0, path_1.join)(process.cwd(), 'fraim-pro', fileName),
11
+ (0, path_1.resolve)(__dirname, '..', '..', 'fraim-pro', fileName),
12
+ (0, path_1.resolve)(__dirname, '..', '..', '..', 'fraim-pro', fileName),
13
+ ];
14
+ for (const candidate of candidates) {
15
+ if ((0, fs_1.existsSync)(candidate))
16
+ return candidate;
17
+ }
18
+ return null;
19
+ }
20
+ class AuthMiddleware {
21
+ constructor(dbService, skipTestBypass = false) {
22
+ this.dbService = dbService;
23
+ this.skipTestBypass = skipTestBypass;
24
+ }
25
+ /**
26
+ * Middleware to authenticate requests via API key
27
+ */
28
+ async handle(req, res, next) {
29
+ // Note: Payment routes are registered BEFORE this middleware is applied
30
+ // Skip auth for public health check, admin routes, website signup, sales inquiries, and self-serve access flow
31
+ const rawPath = req.path ?? req.originalUrl ?? req.url ?? '';
32
+ const p = (typeof rawPath === 'string' ? rawPath : '').split('?')[0].replace(/\/$/, '') || '';
33
+ const publicPrefixes = ['/admin', '/dashboard', '/health', '/pricing', '/fraim-brain', '/api/signup', '/api/sales', '/api/request-access', '/api/installer-key', '/api/installer-download', '/api/installer-availability', '/api/payment/bypass', '/api/pricing', '/api/personas/catalog', '/auth', '/portfolio'];
34
+ // Analytics dashboard is public, but API routes are protected
35
+ const isAnalyticsPublic = p === '/analytics' || p === '/analytics/' || p.startsWith('/analytics/') && p.endsWith('.html');
36
+ // Homepage is public
37
+ if (p === '' || p === '/')
38
+ return next();
39
+ const publicExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2', '.ttf'];
40
+ const isPublic = publicPrefixes.some(prefix => p === prefix || p.startsWith(prefix + '/'))
41
+ || publicExtensions.some(ext => p.endsWith(ext))
42
+ || p.startsWith('/css/') || p.startsWith('/js/') || p.startsWith('/images/')
43
+ || isAnalyticsPublic;
44
+ if (isPublic) {
45
+ return next();
46
+ }
47
+ // Header-only API key transport. Query-string transport was removed because credentials
48
+ // in URLs leak through access logs, browser history, and HTTP Referer headers.
49
+ // Reject any request that attempts to use ?api-key= so silent fallback to header doesn't mask the misuse.
50
+ if (req.query && typeof req.query['api-key'] !== 'undefined') {
51
+ console.error(`[FRAIM AUTH] Rejected query-string api-key for ${req.method} ${req.path}`);
52
+ return res.status(401).json({
53
+ jsonrpc: '2.0',
54
+ error: { code: -32001, message: 'Unauthorized: API keys must be sent in the x-api-key header, not in the query string [AUTH-QUERY-FORBIDDEN]' },
55
+ id: req.body?.id || null
56
+ });
57
+ }
58
+ // Issue #359 — accept FraimAuthSession via cookie or Authorization: Bearer in addition to x-api-key.
59
+ // Priority: x-api-key (programmatic clients) → cookie (web) → bearer (mobile/native).
60
+ const apiKey = req.headers['x-api-key'];
61
+ const sessionFromCookie = (0, cookie_service_1.readSessionCookie)(req);
62
+ const sessionFromBearer = (0, cookie_service_1.readBearerToken)(req);
63
+ const sessionToken = sessionFromCookie || sessionFromBearer;
64
+ // If a session token is present and there's no API key, resolve it.
65
+ if (!apiKey && sessionToken) {
66
+ try {
67
+ const session = await this.dbService.getAuthSession(sessionToken);
68
+ if (!session || session.revoked || session.expiresAt < new Date()) {
69
+ // MCP clients (claude.ai Connectors, Office add-ins, Claude Design) send the
70
+ // FRAIM API key as Authorization: Bearer <key>. The bearer value arrives here
71
+ // as sessionToken but is not a session UUID — it's the raw key. Try it as a
72
+ // direct API key before returning 401.
73
+ if (sessionFromBearer) {
74
+ const directKeyData = await this.dbService.verifyApiKey(sessionFromBearer).catch(() => null);
75
+ const isDirectKeyValid = directKeyData
76
+ && directKeyData.status !== 'suspended'
77
+ && !(directKeyData.status === 'expired' || (directKeyData.expiresAt && new Date() > directKeyData.expiresAt));
78
+ if (isDirectKeyValid) {
79
+ this.dbService.updateApiKeyLastUsed(directKeyData.key).catch(() => undefined);
80
+ req.apiKeyData = directKeyData;
81
+ req.authSource = 'bearer-api-key';
82
+ return next();
83
+ }
84
+ }
85
+ console.error(`[FRAIM AUTH] Invalid/revoked/expired session for ${req.method} ${req.path}`);
86
+ if (sessionFromCookie)
87
+ (0, cookie_service_1.clearSessionCookie)(res);
88
+ (0, audit_log_1.auditLog)('SESSION_REVOKED', {
89
+ userId: session?.userId ?? null,
90
+ sessionId: sessionToken,
91
+ reason: !session ? 'not_found' : session.revoked ? 'revoked' : 'expired',
92
+ outcome: 'failure',
93
+ }).catch(() => undefined);
94
+ return res.status(401).json({
95
+ jsonrpc: '2.0',
96
+ error: { code: -32001, message: 'Unauthorized: Session expired or revoked [AUTH-SESSION-INVALID]' },
97
+ id: req.body?.id || null
98
+ });
99
+ }
100
+ const apiKeyData = await this.dbService.getApiKeyByUserId(session.userId, false);
101
+ if (!apiKeyData) {
102
+ console.error(`[FRAIM AUTH] Session valid but no API key for user ${session.userId}`);
103
+ return res.status(401).json({
104
+ jsonrpc: '2.0',
105
+ error: { code: -32001, message: 'Unauthorized: No identity for session [AUTH-NO-IDENTITY]' },
106
+ id: req.body?.id || null
107
+ });
108
+ }
109
+ this.dbService.touchAuthSession(session.sessionId).catch(err => {
110
+ console.warn('[FRAIM AUTH] Failed to update auth session lastSeenAt:', err);
111
+ });
112
+ req.apiKeyData = apiKeyData;
113
+ req.authSession = session;
114
+ req.authSource = sessionFromCookie ? 'session-cookie' : 'session-bearer';
115
+ return next();
116
+ }
117
+ catch (err) {
118
+ const msg = err instanceof Error ? err.message : String(err);
119
+ console.error('[FRAIM AUTH] Error resolving session:', msg);
120
+ return res.status(500).json({ error: 'Internal Server Error', details: msg });
121
+ }
122
+ }
123
+ // Mixed-identity check: if both a session AND an api key are presented, they must agree.
124
+ if (apiKey && sessionToken) {
125
+ try {
126
+ const [keyData, session] = await Promise.all([
127
+ this.dbService.verifyApiKey(apiKey),
128
+ this.dbService.getAuthSession(sessionToken),
129
+ ]);
130
+ if (keyData && session && !session.revoked && keyData.userId !== session.userId) {
131
+ console.error(`[FRAIM AUTH] MIXED_IDENTITY: api-key user=${keyData.userId} session user=${session.userId}`);
132
+ (0, audit_log_1.auditLog)('MIXED_IDENTITY', {
133
+ userId: keyData.userId,
134
+ sessionId: sessionToken,
135
+ outcome: 'failure',
136
+ reason: `session_user=${session.userId}`,
137
+ }).catch(() => undefined);
138
+ return res.status(401).json({
139
+ jsonrpc: '2.0',
140
+ error: { code: -32001, message: 'Unauthorized: Mixed identity [AUTH-MIXED-IDENTITY]' },
141
+ id: req.body?.id || null
142
+ });
143
+ }
144
+ }
145
+ catch (err) {
146
+ console.warn('[FRAIM AUTH] Mixed-identity check failed:', err instanceof Error ? err.message : String(err));
147
+ }
148
+ }
149
+ // Check for missing API key first
150
+ if (!apiKey) {
151
+ // For browser navigation (HTML Accept, non-API path), serve 404 page instead of 401 JSON
152
+ const acceptsHtml = (req.headers.accept || '').includes('text/html');
153
+ const isApiPath = p.startsWith('/api/') || p.startsWith('/mcp') || p.startsWith('/admin');
154
+ if (acceptsHtml && !isApiPath) {
155
+ const page404 = resolveFraimProFilePath('404.html');
156
+ if (page404)
157
+ return res.status(404).sendFile(page404);
158
+ return res.status(404).send('<h1>404</h1><p>Page not found.</p>');
159
+ }
160
+ console.error(`[FRAIM AUTH] Missing API key for ${req.method} ${req.path}`);
161
+ return res.status(401).json({
162
+ jsonrpc: '2.0',
163
+ error: { code: -32001, message: 'Unauthorized: Missing API key [AUTH-MISSING]' },
164
+ id: req.body?.id || null
165
+ });
166
+ }
167
+ // Auth bypass for Local Sync and Tests
168
+ const rawAuthMode = req.headers['x-fraim-auth-mode'];
169
+ const authMode = Array.isArray(rawAuthMode) ? rawAuthMode[0] : rawAuthMode;
170
+ const strictTestAuth = typeof authMode === 'string' && authMode.toLowerCase() === 'strict';
171
+ const isTestBypass = process.env.NODE_ENV === 'test' && !this.skipTestBypass && !strictTestAuth && apiKey !== 'invalid-key';
172
+ const isLocalDev = apiKey === 'local-dev' && process.env.NODE_ENV !== 'production';
173
+ if (isLocalDev || isTestBypass) {
174
+ req.apiKeyData = {
175
+ key: apiKey,
176
+ userId: isLocalDev ? 'local-dev-user' : 'test-user',
177
+ orgId: isLocalDev ? 'local-dev-org' : 'test-org',
178
+ isActive: true
179
+ };
180
+ return next();
181
+ }
182
+ try {
183
+ const apiKeyData = await this.dbService.verifyApiKey(apiKey);
184
+ if (!apiKeyData) {
185
+ console.error(`❌ FRAIM AUTH: Invalid API key: ${apiKey}`);
186
+ return res.status(401).json({
187
+ jsonrpc: '2.0',
188
+ error: { code: -32001, message: 'Unauthorized: Invalid x-api-key [AUTH-INVALID]' },
189
+ id: req.body?.id || null
190
+ });
191
+ }
192
+ // Check if key is suspended
193
+ if (apiKeyData.status === 'suspended') {
194
+ const reason = apiKeyData.suspensionReason || 'payment failure';
195
+ console.error(`❌ FRAIM AUTH: Suspended API key: ${apiKey} (reason: ${reason})`);
196
+ return res.status(402).json({
197
+ jsonrpc: '2.0',
198
+ error: {
199
+ code: -32002,
200
+ message: `API key suspended due to ${reason}. Please update your payment method.`,
201
+ data: {
202
+ status: 'suspended',
203
+ reason,
204
+ paymentUrl: process.env.STRIPE_BILLING_PORTAL_URL || 'https://fraimworks.ai/billing'
205
+ }
206
+ },
207
+ id: req.body?.id || null
208
+ });
209
+ }
210
+ // Check if key is expired
211
+ if (apiKeyData.status === 'expired' || (apiKeyData.expiresAt && new Date() > apiKeyData.expiresAt)) {
212
+ // Update status to expired if not already
213
+ if (apiKeyData.status !== 'expired') {
214
+ await this.dbService.updateApiKey(apiKey, { status: 'expired' });
215
+ }
216
+ const tier = apiKeyData.tier;
217
+ const message = tier === 'trial'
218
+ ? 'Your 14-day free trial has expired. Please upgrade to continue using FRAIM.'
219
+ : 'Your subscription has expired. Please renew to continue using FRAIM.';
220
+ console.error(`❌ FRAIM AUTH: Expired API key: ${apiKey} (tier: ${tier})`);
221
+ return res.status(402).json({
222
+ jsonrpc: '2.0',
223
+ error: {
224
+ code: -32003,
225
+ message,
226
+ data: {
227
+ status: 'expired',
228
+ tier,
229
+ expiresAt: apiKeyData.expiresAt?.toISOString(),
230
+ paymentUrl: tier === 'trial'
231
+ ? (process.env.STRIPE_CHECKOUT_URL || 'https://fraimworks.ai/pricing')
232
+ : (process.env.STRIPE_BILLING_PORTAL_URL || 'https://fraimworks.ai/billing')
233
+ }
234
+ },
235
+ id: req.body?.id || null
236
+ });
237
+ }
238
+ // Update last used timestamp (async, don't await)
239
+ this.dbService.updateApiKeyLastUsed(apiKey).catch(err => {
240
+ console.warn('[FRAIM AUTH] Failed to update lastUsedAt:', err);
241
+ });
242
+ req.apiKeyData = apiKeyData;
243
+ return next();
244
+ }
245
+ catch (error) {
246
+ const msg = error instanceof Error ? error.message : String(error);
247
+ console.error('❌ FRAIM AUTH: Error during verification:', msg);
248
+ if (error instanceof Error && error.stack)
249
+ console.error(error.stack);
250
+ return res.status(500).json({ error: 'Internal Server Error', details: msg });
251
+ }
252
+ }
253
+ handleAdmin(req, res, next) {
254
+ const adminKey = process.env.FRAIM_ADMIN_KEY;
255
+ if (!adminKey) {
256
+ console.error('⚠️ FRAIM_ADMIN_KEY not configured on server');
257
+ return res.status(503).json({ error: 'Management API disabled (key not set)' });
258
+ }
259
+ const providedKey = req.headers['x-admin-key'];
260
+ if (providedKey !== adminKey) {
261
+ return res.status(403).json({ error: 'Forbidden: Invalid admin key' });
262
+ }
263
+ return next();
264
+ }
265
+ }
266
+ exports.AuthMiddleware = AuthMiddleware;