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.
Files changed (77) hide show
  1. package/dist/src/ai-hub/desktop-main.js +2 -2
  2. package/dist/src/ai-hub/server.js +50 -1
  3. package/dist/src/api/admin/payments.js +33 -0
  4. package/dist/src/api/admin/sales-leads.js +21 -0
  5. package/dist/src/api/payment/create-session.js +338 -0
  6. package/dist/src/api/payment/dashboard-link.js +149 -0
  7. package/dist/src/api/payment/session-details.js +31 -0
  8. package/dist/src/api/payment/webhook.js +587 -0
  9. package/dist/src/api/personas/me.js +29 -0
  10. package/dist/src/api/pricing/get-config.js +25 -0
  11. package/dist/src/api/sales/contact.js +44 -0
  12. package/dist/src/cli/commands/add-provider.js +74 -61
  13. package/dist/src/cli/commands/add-surface.js +128 -0
  14. package/dist/src/cli/commands/login.js +5 -69
  15. package/dist/src/cli/commands/setup.js +27 -347
  16. package/dist/src/cli/distribution/marketplace-bundles.js +580 -0
  17. package/dist/src/cli/fraim.js +2 -0
  18. package/dist/src/cli/mcp/ide-formats.js +5 -3
  19. package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
  20. package/dist/src/cli/providers/local-provider-registry.js +2 -3
  21. package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
  22. package/dist/src/cli/setup/ide-detector.js +34 -14
  23. package/dist/src/config/persona-capability-bundles.js +17 -13
  24. package/dist/src/db/payment-repository.js +61 -0
  25. package/dist/src/first-run/session-service.js +2 -2
  26. package/dist/src/fraim/config-loader.js +11 -0
  27. package/dist/src/fraim/db-service.js +2387 -0
  28. package/dist/src/fraim/issues.js +152 -0
  29. package/dist/src/fraim/template-processor.js +184 -0
  30. package/dist/src/fraim/utils/request-utils.js +23 -0
  31. package/dist/src/local-mcp-server/stdio-server.js +28 -4
  32. package/dist/src/local-mcp-server/usage-collector.js +24 -0
  33. package/dist/src/middleware/auth.js +266 -0
  34. package/dist/src/middleware/cors-config.js +111 -0
  35. package/dist/src/middleware/logger.js +116 -0
  36. package/dist/src/middleware/rate-limit.js +110 -0
  37. package/dist/src/middleware/reject-query-api-key.js +45 -0
  38. package/dist/src/middleware/security-headers.js +41 -0
  39. package/dist/src/middleware/telemetry.js +134 -0
  40. package/dist/src/models/payment.js +2 -0
  41. package/dist/src/routes/analytics.js +1447 -0
  42. package/dist/src/routes/app-routes.js +32 -0
  43. package/dist/src/routes/auth-routes.js +505 -0
  44. package/dist/src/routes/oauth-routes.js +325 -0
  45. package/dist/src/routes/payment-routes.js +186 -0
  46. package/dist/src/routes/persona-catalog-routes.js +84 -0
  47. package/dist/src/services/admin-service.js +229 -0
  48. package/dist/src/services/audit-log-persistence.js +60 -0
  49. package/dist/src/services/audit-log.js +69 -0
  50. package/dist/src/services/cookie-service.js +129 -0
  51. package/dist/src/services/dashboard-access.js +27 -0
  52. package/dist/src/services/demo-seed-service.js +139 -0
  53. package/dist/src/services/email-code.js +23 -0
  54. package/dist/src/services/email-service-clean.js +782 -0
  55. package/dist/src/services/email-service.js +951 -0
  56. package/dist/src/services/installer-service.js +131 -0
  57. package/dist/src/services/mcp-oauth-store.js +33 -0
  58. package/dist/src/services/mcp-service.js +823 -0
  59. package/dist/src/services/oauth-helpers.js +127 -0
  60. package/dist/src/services/org-service.js +89 -0
  61. package/dist/src/services/persona-entitlement-service.js +288 -0
  62. package/dist/src/services/provider-service.js +215 -0
  63. package/dist/src/services/registry-service.js +628 -0
  64. package/dist/src/services/session-service.js +86 -0
  65. package/dist/src/services/trial-reminder-service.js +120 -0
  66. package/dist/src/services/usage-analytics-service.js +419 -0
  67. package/dist/src/services/workspace-identity.js +21 -0
  68. package/dist/src/types/analytics.js +2 -0
  69. package/dist/src/utils/payment-calculator.js +52 -0
  70. package/extensions/office-word/favicon.ico +0 -0
  71. package/extensions/office-word/icon-64.png +0 -0
  72. package/extensions/office-word/manifest.xml +33 -0
  73. package/extensions/office-word/taskpane.html +242 -0
  74. package/package.json +14 -3
  75. package/public/ai-hub/index.html +14 -2
  76. package/public/ai-hub/script.js +340 -66
  77. 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
+ }