fraim-framework 2.0.177 → 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.
- package/dist/src/ai-hub/desktop-main.js +2 -2
- package/dist/src/ai-hub/server.js +50 -1
- package/dist/src/api/admin/payments.js +33 -0
- package/dist/src/api/admin/sales-leads.js +21 -0
- package/dist/src/api/payment/create-session.js +338 -0
- package/dist/src/api/payment/dashboard-link.js +149 -0
- package/dist/src/api/payment/session-details.js +31 -0
- package/dist/src/api/payment/webhook.js +587 -0
- package/dist/src/api/personas/me.js +29 -0
- package/dist/src/api/pricing/get-config.js +25 -0
- package/dist/src/api/sales/contact.js +44 -0
- package/dist/src/cli/commands/add-provider.js +74 -61
- package/dist/src/cli/commands/add-surface.js +128 -0
- package/dist/src/cli/commands/login.js +5 -69
- package/dist/src/cli/commands/setup.js +27 -347
- package/dist/src/cli/distribution/marketplace-bundles.js +580 -0
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/mcp/ide-formats.js +5 -3
- package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
- package/dist/src/cli/providers/local-provider-registry.js +2 -3
- package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
- package/dist/src/cli/setup/ide-detector.js +34 -14
- package/dist/src/config/persona-capability-bundles.js +17 -13
- package/dist/src/db/payment-repository.js +61 -0
- package/dist/src/first-run/session-service.js +2 -2
- package/dist/src/fraim/config-loader.js +11 -0
- package/dist/src/fraim/db-service.js +2387 -0
- package/dist/src/fraim/issues.js +152 -0
- package/dist/src/fraim/template-processor.js +184 -0
- package/dist/src/fraim/utils/request-utils.js +23 -0
- package/dist/src/local-mcp-server/stdio-server.js +28 -4
- package/dist/src/local-mcp-server/usage-collector.js +24 -0
- package/dist/src/middleware/auth.js +266 -0
- package/dist/src/middleware/cors-config.js +111 -0
- package/dist/src/middleware/logger.js +116 -0
- package/dist/src/middleware/rate-limit.js +110 -0
- package/dist/src/middleware/reject-query-api-key.js +45 -0
- package/dist/src/middleware/security-headers.js +41 -0
- package/dist/src/middleware/telemetry.js +134 -0
- package/dist/src/models/payment.js +2 -0
- package/dist/src/routes/analytics.js +1447 -0
- package/dist/src/routes/app-routes.js +32 -0
- package/dist/src/routes/auth-routes.js +505 -0
- package/dist/src/routes/oauth-routes.js +325 -0
- package/dist/src/routes/payment-routes.js +186 -0
- package/dist/src/routes/persona-catalog-routes.js +84 -0
- package/dist/src/services/admin-service.js +229 -0
- package/dist/src/services/audit-log-persistence.js +60 -0
- package/dist/src/services/audit-log.js +69 -0
- package/dist/src/services/cookie-service.js +129 -0
- package/dist/src/services/dashboard-access.js +27 -0
- package/dist/src/services/demo-seed-service.js +139 -0
- package/dist/src/services/email-code.js +23 -0
- package/dist/src/services/email-service-clean.js +782 -0
- package/dist/src/services/email-service.js +951 -0
- package/dist/src/services/installer-service.js +131 -0
- package/dist/src/services/mcp-oauth-store.js +33 -0
- package/dist/src/services/mcp-service.js +823 -0
- package/dist/src/services/oauth-helpers.js +127 -0
- package/dist/src/services/org-service.js +89 -0
- package/dist/src/services/persona-entitlement-service.js +288 -0
- package/dist/src/services/provider-service.js +215 -0
- package/dist/src/services/registry-service.js +628 -0
- package/dist/src/services/session-service.js +86 -0
- package/dist/src/services/trial-reminder-service.js +120 -0
- package/dist/src/services/usage-analytics-service.js +419 -0
- package/dist/src/services/workspace-identity.js +21 -0
- package/dist/src/types/analytics.js +2 -0
- package/dist/src/utils/payment-calculator.js +52 -0
- package/extensions/office-word/favicon.ico +0 -0
- package/extensions/office-word/icon-64.png +0 -0
- package/extensions/office-word/manifest.xml +33 -0
- package/extensions/office-word/taskpane.html +242 -0
- package/package.json +14 -3
- package/public/ai-hub/index.html +14 -2
- package/public/ai-hub/script.js +340 -66
- package/public/ai-hub/styles.css +83 -0
|
@@ -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;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildCorsOptions = buildCorsOptions;
|
|
4
|
+
exports.resolveSseAllowedOrigin = resolveSseAllowedOrigin;
|
|
5
|
+
const git_utils_1 = require("../core/utils/git-utils");
|
|
6
|
+
/**
|
|
7
|
+
* CORS allowlist configuration for the FRAIM server.
|
|
8
|
+
*
|
|
9
|
+
* Lives in its own module (not src/fraim-mcp-server.ts) so tests can import
|
|
10
|
+
* `buildCorsOptions` and `resolveSseAllowedOrigin` without running the
|
|
11
|
+
* top-level server bootstrap as a side effect.
|
|
12
|
+
*
|
|
13
|
+
* Allowlist sources (merged):
|
|
14
|
+
* - FRAIM_CORS_ALLOWED_ORIGINS (comma-separated absolute origins, e.g.
|
|
15
|
+
* "https://app.example.com,https://staging.example.com")
|
|
16
|
+
* - Built-in production origins for fraimworks.ai
|
|
17
|
+
* - Localhost dev origins when NODE_ENV !== 'production'
|
|
18
|
+
*
|
|
19
|
+
* Requests without an Origin header are allowed for curl/server-to-server
|
|
20
|
+
* callers. Browser POST/fetch requests can still include Origin on same-origin
|
|
21
|
+
* requests, so the active local FRAIM port must be allowlisted in development.
|
|
22
|
+
*/
|
|
23
|
+
function buildAllowlist() {
|
|
24
|
+
const fromEnv = (process.env.FRAIM_CORS_ALLOWED_ORIGINS || '')
|
|
25
|
+
.split(',')
|
|
26
|
+
.map(o => o.trim())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
const publicBaseUrl = (process.env.FRAIM_PUBLIC_BASE_URL || '').replace(/\/+$/, '').trim();
|
|
29
|
+
const allowed = new Set([
|
|
30
|
+
...fromEnv,
|
|
31
|
+
'https://fraimworks.ai',
|
|
32
|
+
'https://www.fraimworks.ai',
|
|
33
|
+
'https://fraim.wellnessatwork.me',
|
|
34
|
+
// Claude surfaces — browser-originated requests from claude.ai web app,
|
|
35
|
+
// Claude Design, and claude.ai settings pages (Connectors setup flow).
|
|
36
|
+
// Anthropic's backend MCP calls have no Origin header so don't need listing.
|
|
37
|
+
'https://claude.ai',
|
|
38
|
+
'https://www.claude.ai',
|
|
39
|
+
'https://design.claude.ai',
|
|
40
|
+
'https://api.anthropic.com',
|
|
41
|
+
]);
|
|
42
|
+
if (publicBaseUrl && /^https?:\/\//i.test(publicBaseUrl)) {
|
|
43
|
+
allowed.add(publicBaseUrl);
|
|
44
|
+
}
|
|
45
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
46
|
+
const localPorts = new Set([3000, 3002, 8080, 8765]);
|
|
47
|
+
const addPort = (value) => {
|
|
48
|
+
if (!value)
|
|
49
|
+
return;
|
|
50
|
+
const parsed = Number(value);
|
|
51
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
52
|
+
localPorts.add(parsed);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
addPort(String((0, git_utils_1.getPort)()));
|
|
56
|
+
addPort(process.env.FRAIM_MCP_PORT);
|
|
57
|
+
addPort(process.env.PORT);
|
|
58
|
+
addPort(process.env.WEBSITES_PORT);
|
|
59
|
+
addPort(process.env.PLAYWRIGHT_FRAIM_MCP_PORT);
|
|
60
|
+
addPort(process.env.PLAYWRIGHT_WEBSITE_PORT);
|
|
61
|
+
const playwrightBaseUrl = (process.env.PLAYWRIGHT_BASE_URL || '').trim();
|
|
62
|
+
if (playwrightBaseUrl) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = new URL(playwrightBaseUrl);
|
|
65
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
66
|
+
allowed.add(parsed.origin);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Ignore invalid PLAYWRIGHT_BASE_URL values and fall back to the static allowlist.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const localPort of localPorts) {
|
|
74
|
+
allowed.add(`http://localhost:${localPort}`);
|
|
75
|
+
allowed.add(`http://127.0.0.1:${localPort}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return allowed;
|
|
79
|
+
}
|
|
80
|
+
function buildCorsOptions() {
|
|
81
|
+
const allowed = buildAllowlist();
|
|
82
|
+
return {
|
|
83
|
+
origin: (origin, callback) => {
|
|
84
|
+
// Same-origin and non-browser tools have no Origin header.
|
|
85
|
+
// This covers Anthropic's cloud infrastructure making server-to-server
|
|
86
|
+
// MCP calls on behalf of claude.ai Connectors — no Origin header present.
|
|
87
|
+
if (!origin)
|
|
88
|
+
return callback(null, true);
|
|
89
|
+
if (allowed.has(origin))
|
|
90
|
+
return callback(null, true);
|
|
91
|
+
return callback(new Error(`CORS: origin ${origin} is not in the allowlist`));
|
|
92
|
+
},
|
|
93
|
+
credentials: false,
|
|
94
|
+
methods: ['GET', 'POST', 'OPTIONS'],
|
|
95
|
+
// Authorization: required by MCP HTTP transport for Bearer token auth
|
|
96
|
+
// mcp-session-id: required by MCP Streamable HTTP spec for session tracking
|
|
97
|
+
allowedHeaders: ['Content-Type', 'x-api-key', 'x-admin-key', 'x-fraim-auth-mode', 'x-fraim-local-version', 'x-fraim-session-id', 'Authorization', 'mcp-session-id'],
|
|
98
|
+
exposedHeaders: ['mcp-session-id'],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the SSE Access-Control-Allow-Origin value against the allowlist.
|
|
103
|
+
* Returns the request's Origin if it is allowed, otherwise undefined so the
|
|
104
|
+
* caller can omit the header entirely. Never returns the wildcard '*'.
|
|
105
|
+
*/
|
|
106
|
+
function resolveSseAllowedOrigin(origin) {
|
|
107
|
+
if (!origin)
|
|
108
|
+
return undefined;
|
|
109
|
+
const allowed = buildAllowlist();
|
|
110
|
+
return allowed.has(origin) ? origin : undefined;
|
|
111
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.requestLogger = requestLogger;
|
|
37
|
+
const crypto_1 = require("crypto");
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const LOG_FILE = path.join(process.cwd(), 'server-direct.log');
|
|
41
|
+
/**
|
|
42
|
+
* Extracts API key from request for logging purposes
|
|
43
|
+
*/
|
|
44
|
+
function getApiKeyForLogging(req) {
|
|
45
|
+
const fromContext = req.apiKeyData?.key;
|
|
46
|
+
if (typeof fromContext === 'string' && fromContext.trim().length > 0) {
|
|
47
|
+
return fromContext;
|
|
48
|
+
}
|
|
49
|
+
const fromHeader = Array.isArray(req.headers['x-api-key'])
|
|
50
|
+
? req.headers['x-api-key'][0]
|
|
51
|
+
: req.headers['x-api-key'];
|
|
52
|
+
if (typeof fromHeader === 'string' && fromHeader.trim().length > 0) {
|
|
53
|
+
return fromHeader;
|
|
54
|
+
}
|
|
55
|
+
const fromQuery = req.query?.['api-key'];
|
|
56
|
+
if (typeof fromQuery === 'string' && fromQuery.trim().length > 0) {
|
|
57
|
+
return fromQuery;
|
|
58
|
+
}
|
|
59
|
+
return 'ANONYMOUS';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Middleware to log all requests
|
|
63
|
+
*/
|
|
64
|
+
function requestLogger(req, res, next) {
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
const { method, path } = req;
|
|
67
|
+
const requestIdHeader = Array.isArray(req.headers['x-fraim-request-id'])
|
|
68
|
+
? req.headers['x-fraim-request-id'][0]
|
|
69
|
+
: req.headers['x-fraim-request-id'];
|
|
70
|
+
const requestId = typeof requestIdHeader === 'string' && requestIdHeader.trim().length > 0
|
|
71
|
+
? requestIdHeader
|
|
72
|
+
: (0, crypto_1.randomUUID)();
|
|
73
|
+
const localVersionHeader = Array.isArray(req.headers['x-fraim-local-version'])
|
|
74
|
+
? req.headers['x-fraim-local-version'][0]
|
|
75
|
+
: req.headers['x-fraim-local-version'];
|
|
76
|
+
const localMcpVersion = typeof localVersionHeader === 'string' && localVersionHeader.trim().length > 0
|
|
77
|
+
? localVersionHeader
|
|
78
|
+
: 'unknown';
|
|
79
|
+
req.requestId = requestId;
|
|
80
|
+
req.localMcpVersion = localMcpVersion;
|
|
81
|
+
res.setHeader('X-FRAIM-Request-ID', requestId);
|
|
82
|
+
let params = '';
|
|
83
|
+
if (method === 'POST') {
|
|
84
|
+
try {
|
|
85
|
+
params = JSON.stringify(req.body);
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
params = '[Unserializable Body]';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const logEntry = (msg) => {
|
|
92
|
+
const timestamp = new Date().toISOString();
|
|
93
|
+
const entry = `[${timestamp}] ${msg}\n`;
|
|
94
|
+
try {
|
|
95
|
+
fs.appendFileSync(LOG_FILE, entry);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
console.error('Failed to write to log file:', e);
|
|
99
|
+
}
|
|
100
|
+
if (msg.includes('END') && msg.includes(' 400')) {
|
|
101
|
+
console.error(msg); // Still log errors to console
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
console.info(msg);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
logEntry(`[req:${requestId}] START ${method} ${path}${params ? ' ' + params : ''}`);
|
|
108
|
+
res.on('finish', () => {
|
|
109
|
+
const duration = Date.now() - start;
|
|
110
|
+
const status = res.statusCode;
|
|
111
|
+
const keyStr = getApiKeyForLogging(req);
|
|
112
|
+
const logMsg = `[req:${requestId}] [local:${localMcpVersion}] ${keyStr} END ${method} ${path} ${status} ${duration}ms`;
|
|
113
|
+
logEntry(logMsg);
|
|
114
|
+
});
|
|
115
|
+
next();
|
|
116
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sensitiveRateLimiter = exports.publicApiRateLimiter = void 0;
|
|
4
|
+
exports.createRateLimiter = createRateLimiter;
|
|
5
|
+
function createRateLimiter(options) {
|
|
6
|
+
const { windowMs, max, name } = options;
|
|
7
|
+
const buckets = new Map();
|
|
8
|
+
function cleanupExpired(now = Date.now()) {
|
|
9
|
+
let removed = 0;
|
|
10
|
+
for (const [key, bucket] of buckets) {
|
|
11
|
+
if (bucket.resetAt <= now) {
|
|
12
|
+
buckets.delete(key);
|
|
13
|
+
removed += 1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return removed;
|
|
17
|
+
}
|
|
18
|
+
// Periodically sweep expired buckets so long-lived processes do not leak memory
|
|
19
|
+
// proportional to lifetime client-IP cardinality. unref() so the timer never holds
|
|
20
|
+
// the event loop open during shutdown.
|
|
21
|
+
let sweepTimer = null;
|
|
22
|
+
if (process.env.NODE_ENV !== 'test' || process.env.FRAIM_RATE_LIMIT_SWEEP === '1') {
|
|
23
|
+
sweepTimer = setInterval(() => {
|
|
24
|
+
const removed = cleanupExpired();
|
|
25
|
+
if (removed > 0) {
|
|
26
|
+
console.log(`[FRAIM RATE-LIMIT] ${name} swept ${removed} expired buckets (remaining=${buckets.size})`);
|
|
27
|
+
}
|
|
28
|
+
}, windowMs);
|
|
29
|
+
sweepTimer.unref();
|
|
30
|
+
}
|
|
31
|
+
const middleware = function rateLimitMiddleware(req, res, next) {
|
|
32
|
+
// Rate limits are skipped in test mode so the suite can run hundreds of requests
|
|
33
|
+
// against the same endpoint quickly. Production and dev still enforce them.
|
|
34
|
+
if (process.env.NODE_ENV === 'test' && process.env.FRAIM_TEST_RATE_LIMIT !== '1') {
|
|
35
|
+
return next();
|
|
36
|
+
}
|
|
37
|
+
const key = identifyClient(req);
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const bucket = buckets.get(key);
|
|
40
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
41
|
+
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
42
|
+
res.setHeader('X-RateLimit-Limit', String(max));
|
|
43
|
+
res.setHeader('X-RateLimit-Remaining', String(max - 1));
|
|
44
|
+
res.setHeader('X-RateLimit-Reset', String(Math.ceil((now + windowMs) / 1000)));
|
|
45
|
+
return next();
|
|
46
|
+
}
|
|
47
|
+
if (bucket.count >= max) {
|
|
48
|
+
const retryAfterSec = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
|
49
|
+
res.setHeader('Retry-After', String(retryAfterSec));
|
|
50
|
+
res.setHeader('X-RateLimit-Limit', String(max));
|
|
51
|
+
res.setHeader('X-RateLimit-Remaining', '0');
|
|
52
|
+
res.setHeader('X-RateLimit-Reset', String(Math.ceil(bucket.resetAt / 1000)));
|
|
53
|
+
console.warn(`[FRAIM RATE-LIMIT] ${name} blocked ${key} on ${req.method} ${req.path}`);
|
|
54
|
+
return res.status(429).json({
|
|
55
|
+
error: 'Too many requests',
|
|
56
|
+
message: `Rate limit exceeded. Try again in ${retryAfterSec} seconds.`
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
bucket.count += 1;
|
|
60
|
+
res.setHeader('X-RateLimit-Limit', String(max));
|
|
61
|
+
res.setHeader('X-RateLimit-Remaining', String(max - bucket.count));
|
|
62
|
+
res.setHeader('X-RateLimit-Reset', String(Math.ceil(bucket.resetAt / 1000)));
|
|
63
|
+
return next();
|
|
64
|
+
};
|
|
65
|
+
middleware.reset = () => buckets.clear();
|
|
66
|
+
middleware.cleanupExpired = cleanupExpired;
|
|
67
|
+
middleware.stopCleanup = () => {
|
|
68
|
+
if (sweepTimer) {
|
|
69
|
+
clearInterval(sweepTimer);
|
|
70
|
+
sweepTimer = null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
Object.defineProperty(middleware, 'bucketCount', {
|
|
74
|
+
get: () => buckets.size
|
|
75
|
+
});
|
|
76
|
+
return middleware;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Identify the client for rate-limit bucketing.
|
|
80
|
+
* Order: explicit Express-resolved req.ip → first X-Forwarded-For hop → socket address → 'unknown'.
|
|
81
|
+
*/
|
|
82
|
+
function identifyClient(req) {
|
|
83
|
+
if (req.ip)
|
|
84
|
+
return req.ip;
|
|
85
|
+
const xff = req.headers['x-forwarded-for'];
|
|
86
|
+
if (typeof xff === 'string' && xff.length > 0) {
|
|
87
|
+
const first = xff.split(',')[0].trim();
|
|
88
|
+
if (first)
|
|
89
|
+
return first;
|
|
90
|
+
}
|
|
91
|
+
else if (Array.isArray(xff) && xff.length > 0) {
|
|
92
|
+
const first = xff[0].split(',')[0].trim();
|
|
93
|
+
if (first)
|
|
94
|
+
return first;
|
|
95
|
+
}
|
|
96
|
+
return req.socket?.remoteAddress || 'unknown';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Standard public-API limiter: 60 requests per minute per IP.
|
|
100
|
+
* Suitable for read-only public endpoints (pricing config, session details).
|
|
101
|
+
*/
|
|
102
|
+
const publicApiRateLimiter = () => createRateLimiter({ windowMs: 60_000, max: 60, name: 'public-api' });
|
|
103
|
+
exports.publicApiRateLimiter = publicApiRateLimiter;
|
|
104
|
+
/**
|
|
105
|
+
* Sensitive-action limiter: 10 requests per minute per IP.
|
|
106
|
+
* Suitable for signup, contact, bypass, key-retrieval, and other endpoints
|
|
107
|
+
* whose abuse has direct cost or fraud impact.
|
|
108
|
+
*/
|
|
109
|
+
const sensitiveRateLimiter = () => createRateLimiter({ windowMs: 60_000, max: 10, name: 'sensitive' });
|
|
110
|
+
exports.sensitiveRateLimiter = sensitiveRateLimiter;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.rejectQueryStringApiKey = rejectQueryStringApiKey;
|
|
4
|
+
/**
|
|
5
|
+
* Reject any API/MCP/admin request that carries an api-key in the query string.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists separately from the AuthMiddleware's same check:
|
|
8
|
+
* The AuthMiddleware short-circuits on public prefixes (e.g. /api/pricing/config),
|
|
9
|
+
* which means a request like `/api/pricing/config?api-key=secret` previously slipped
|
|
10
|
+
* the credential into URLs and access logs even though the route did not actually
|
|
11
|
+
* authenticate it. This middleware runs BEFORE routing, so it catches that case.
|
|
12
|
+
*
|
|
13
|
+
* Why it is scoped to API paths and not applied globally:
|
|
14
|
+
* The analytics dashboard navigates between its public HTML pages with
|
|
15
|
+
* `?api-key=` in the URL so the front-end can store the key in sessionStorage
|
|
16
|
+
* and then send it via header on subsequent XHRs. That page-navigation pattern
|
|
17
|
+
* is intentional and preserved. The leak we close here is the API-call pattern
|
|
18
|
+
* where the credential ends up in proxy/access logs.
|
|
19
|
+
*
|
|
20
|
+
* Paths covered: /api/*, /mcp, /mcp/*, /admin, /admin/*.
|
|
21
|
+
*/
|
|
22
|
+
function rejectQueryStringApiKey() {
|
|
23
|
+
return function (req, res, next) {
|
|
24
|
+
const path = req.path || '';
|
|
25
|
+
const isApiPath = path.startsWith('/api/')
|
|
26
|
+
|| path === '/mcp'
|
|
27
|
+
|| path.startsWith('/mcp/')
|
|
28
|
+
|| path === '/admin'
|
|
29
|
+
|| path.startsWith('/admin/');
|
|
30
|
+
if (!isApiPath)
|
|
31
|
+
return next();
|
|
32
|
+
if (req.query && typeof req.query['api-key'] !== 'undefined') {
|
|
33
|
+
console.error(`[FRAIM AUTH] Rejected query-string api-key (pre-routing) for ${req.method} ${path}`);
|
|
34
|
+
return res.status(401).json({
|
|
35
|
+
jsonrpc: '2.0',
|
|
36
|
+
error: {
|
|
37
|
+
code: -32001,
|
|
38
|
+
message: 'Unauthorized: API keys must be sent in the x-api-key header, not in the query string [AUTH-QUERY-FORBIDDEN]'
|
|
39
|
+
},
|
|
40
|
+
id: req.body?.id || null
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return next();
|
|
44
|
+
};
|
|
45
|
+
}
|