fraim 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,32 @@
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.registerAppRoutes = registerAppRoutes;
7
+ const path_1 = __importDefault(require("path"));
8
+ /**
9
+ * Serve the unified tabs experience at the root path.
10
+ *
11
+ * Authenticated users see the tabbed interface with Analytics, Brain, and Account.
12
+ * Unauthenticated users are redirected to sign-in.
13
+ */
14
+ function registerAppRoutes(app, deps) {
15
+ const { dbService } = deps;
16
+ /**
17
+ * GET / - Main authenticated app (unified tabs)
18
+ *
19
+ * Serves the unified tabs page. Client-side JavaScript checks session via
20
+ * /api/auth/session and redirects to sign-in if not authenticated.
21
+ */
22
+ app.get('/', (req, res) => {
23
+ try {
24
+ const indexPath = path_1.default.join(process.cwd(), 'public', 'index.html');
25
+ return res.sendFile(indexPath);
26
+ }
27
+ catch (err) {
28
+ console.error('Error serving root page:', err);
29
+ return res.status(500).send('Internal server error');
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,505 @@
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.registerPublicAuthRoutes = registerPublicAuthRoutes;
37
+ exports.registerProtectedAuthRoutes = registerProtectedAuthRoutes;
38
+ exports.__resetAuthRouteRateLimitsForTests = __resetAuthRouteRateLimitsForTests;
39
+ const crypto_1 = require("crypto");
40
+ const email_service_1 = require("../services/email-service");
41
+ const cookie_service_1 = require("../services/cookie-service");
42
+ const oauth_helpers_1 = require("../services/oauth-helpers");
43
+ const audit_log_1 = require("../services/audit-log");
44
+ const email_code_1 = require("../services/email-code");
45
+ const MAX_EMAIL_REQUESTS_PER_EMAIL_PER_10MIN = 5;
46
+ const MAX_EMAIL_REQUESTS_PER_IP_PER_10MIN = 20;
47
+ const recentEmailRequests = new Map();
48
+ // ─── mobile exchange code store ─────────────────────────────────────────────
49
+ // Short-lived one-time codes bridging web OAuth/email-code sign-in to the mobile app.
50
+ // Key: 12-char hex code Value: { apiKey, expiresAt }
51
+ const MOBILE_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
52
+ const mobileCodeStore = new Map();
53
+ function pruneMobileCodes() {
54
+ const now = Date.now();
55
+ for (const [code, entry] of mobileCodeStore.entries()) {
56
+ if (entry.expiresAt < now)
57
+ mobileCodeStore.delete(code);
58
+ }
59
+ }
60
+ function issueMobileExchangeCode(apiKey) {
61
+ pruneMobileCodes();
62
+ const code = (0, crypto_1.randomBytes)(12).toString('hex'); // 24 chars
63
+ mobileCodeStore.set(code, { apiKey, expiresAt: Date.now() + MOBILE_CODE_TTL_MS });
64
+ return code;
65
+ }
66
+ function serializeApiKeyLifecycle(apiKeyData) {
67
+ const rawExpiresAt = apiKeyData?.expiresAt ?? null;
68
+ const parsedExpiresAt = rawExpiresAt ? new Date(rawExpiresAt) : null;
69
+ return {
70
+ status: apiKeyData?.status ?? 'active',
71
+ expiresAt: parsedExpiresAt && !Number.isNaN(parsedExpiresAt.getTime())
72
+ ? parsedExpiresAt.toISOString()
73
+ : null,
74
+ };
75
+ }
76
+ const recentIpRequests = new Map();
77
+ function pruneOlder(arr, cutoff) {
78
+ return arr.filter(t => t >= cutoff);
79
+ }
80
+ function recordAndCheckRate(map, key, max) {
81
+ const now = Date.now();
82
+ const cutoff = now - 10 * 60 * 1000;
83
+ const existing = map.get(key) ?? [];
84
+ const recent = pruneOlder(existing, cutoff);
85
+ recent.push(now);
86
+ map.set(key, recent);
87
+ return recent.length <= max;
88
+ }
89
+ const PENDING_VERIFICATION_TTL_MS = 15 * 60 * 1000;
90
+ /**
91
+ * Build the base URL used in email-code sign-in emails.
92
+ *
93
+ * Preference order:
94
+ * 1. FRAIM_PUBLIC_BASE_URL env var (explicit canonical override)
95
+ * 2. X-Forwarded-Proto + X-Forwarded-Host from the request
96
+ *
97
+ * Azure App Service sets X-Forwarded-Host to the hostname the user actually
98
+ * navigated to (validated against configured custom domains / the default
99
+ * azurewebsites.net hostname), so the derived URL is always a legitimate
100
+ * origin — no Host-header injection risk in this deployment topology.
101
+ * Using the request-derived host means sign-in CTA links work correctly regardless
102
+ * of which of the app's configured hostnames the user accessed.
103
+ */
104
+ function getCanonicalBaseUrl(req) {
105
+ const fromEnv = process.env.FRAIM_PUBLIC_BASE_URL;
106
+ if (fromEnv && /^https?:\/\//i.test(fromEnv)) {
107
+ return fromEnv.replace(/\/+$/, '');
108
+ }
109
+ const proto = req.headers['x-forwarded-proto'] || req.protocol || 'https';
110
+ const host = req.headers['x-forwarded-host'] || req.get('host') || 'localhost';
111
+ return `${proto}://${host}`;
112
+ }
113
+ function defaultSignInUrl(req) {
114
+ const base = getCanonicalBaseUrl(req);
115
+ return `${base}/auth/sign-in.html`;
116
+ }
117
+ /**
118
+ * Issue #359 — wire OAuth-first login + email-code recovery + logout endpoints.
119
+ *
120
+ * Public routes (no auth middleware required):
121
+ * POST /api/auth/email/request — send a one-time sign-in code
122
+ * POST /api/auth/email/verify-code — consume sign-in code, create session
123
+ *
124
+ * Authenticated routes (run after auth middleware):
125
+ * POST /api/auth/logout — revoke current session
126
+ * POST /api/auth/logout-all — revoke every session for current user
127
+ * GET /api/auth/session — return basic identity for current session
128
+ */
129
+ function registerPublicAuthRoutes(app, deps) {
130
+ const { dbService } = deps;
131
+ let cachedEmailService = deps.emailService;
132
+ const getEmailService = () => {
133
+ if (!cachedEmailService)
134
+ cachedEmailService = new email_service_1.EmailService();
135
+ return cachedEmailService;
136
+ };
137
+ // Startup diagnostics.
138
+ console.log('[FRAIM Auth] startup config check:', {
139
+ FRAIM_PUBLIC_BASE_URL: process.env.FRAIM_PUBLIC_BASE_URL
140
+ ? `(set: ${process.env.FRAIM_PUBLIC_BASE_URL})`
141
+ : '(not set — will derive URL from X-Forwarded-Host at request time)',
142
+ RESEND_API_KEY: process.env.RESEND_API_KEY ? '(set)' : '(NOT SET — email codes will not send)',
143
+ RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL || '(not set — using Resend default sender onboarding@resend.dev)',
144
+ NODE_ENV: process.env.NODE_ENV || '(not set)',
145
+ });
146
+ app.post('/api/auth/email/request', async (req, res) => {
147
+ try {
148
+ const email = (req.body && (req.body.email ?? req.body.userEmail));
149
+ const surface = (0, oauth_helpers_1.pickSurfaceOrDefault)(req.body?.surface);
150
+ if (!(0, oauth_helpers_1.isLikelyValidEmail)(email)) {
151
+ return res.status(200).json({ ok: true });
152
+ }
153
+ const lower = email.toLowerCase();
154
+ const ip = (0, oauth_helpers_1.clientIpFromReq)(req);
155
+ const ipOk = recordAndCheckRate(recentIpRequests, ip, MAX_EMAIL_REQUESTS_PER_IP_PER_10MIN);
156
+ if (!ipOk) {
157
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_RATE_LIMITED', { ip, reason: 'per_ip', outcome: 'failure' });
158
+ return res.status(429).json({ ok: false, error: 'rate_limited' });
159
+ }
160
+ const emailOk = recordAndCheckRate(recentEmailRequests, lower, MAX_EMAIL_REQUESTS_PER_EMAIL_PER_10MIN);
161
+ if (!emailOk) {
162
+ // Always 200 to keep the response enumeration-resistant; just don't send the email.
163
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_RATE_LIMITED', { userId: lower, ip, reason: 'per_email', outcome: 'failure' });
164
+ return res.status(200).json({ ok: true });
165
+ }
166
+ const token = (0, crypto_1.randomBytes)(32).toString('hex');
167
+ const signInCode = (0, email_code_1.generateEmailCode)();
168
+ const now = new Date();
169
+ try {
170
+ await dbService.createPendingVerification({
171
+ token,
172
+ codeHash: (0, email_code_1.hashEmailCode)(lower, signInCode),
173
+ email: lower,
174
+ verified: false,
175
+ expiresAt: new Date(now.getTime() + PENDING_VERIFICATION_TTL_MS),
176
+ createdAt: now,
177
+ usedAt: null,
178
+ });
179
+ const url = defaultSignInUrl(req);
180
+ await getEmailService().sendEmailCode(lower, url, signInCode, { purpose: 'sign-in' });
181
+ }
182
+ catch (innerErr) {
183
+ // Fires when RESEND_API_KEY is missing/invalid, the sender domain (RESEND_FROM_EMAIL) is not
184
+ // verified in Resend, or the token/DB write fails. Returns 200 to prevent email enumeration.
185
+ console.error('[FRAIM AUTH] email-code send failed — check RESEND_API_KEY and verify the sender domain in Resend dashboard (RESEND_FROM_EMAIL):', innerErr instanceof Error ? innerErr.message : String(innerErr));
186
+ }
187
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_REQUESTED', { userId: lower, ip, surface, outcome: 'success' });
188
+ return res.status(200).json({ ok: true });
189
+ }
190
+ catch (err) {
191
+ console.error('[FRAIM AUTH] /api/auth/email/request error:', err instanceof Error ? err.message : String(err));
192
+ return res.status(200).json({ ok: true });
193
+ }
194
+ });
195
+ app.post('/api/auth/email/verify-code', async (req, res) => {
196
+ try {
197
+ const emailInput = req.body?.email;
198
+ const codeInput = (0, email_code_1.normalizeEmailCode)(req.body?.code);
199
+ const surface = (0, oauth_helpers_1.pickSurfaceOrDefault)(req.body?.surface);
200
+ const requestedRedirect = (0, oauth_helpers_1.safeRedirectPath)(req.body?.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
201
+ if (!(0, oauth_helpers_1.isLikelyValidEmail)(emailInput) || codeInput.length < 6) {
202
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_CONSUMED', { reason: 'invalid_input', outcome: 'failure' });
203
+ return res.status(400).json({ ok: false, error: 'invalid_code' });
204
+ }
205
+ const lower = String(emailInput).toLowerCase();
206
+ const ip = (0, oauth_helpers_1.clientIpFromReq)(req);
207
+ const ipOk = recordAndCheckRate(recentIpRequests, `code:${ip}`, MAX_EMAIL_REQUESTS_PER_IP_PER_10MIN);
208
+ if (!ipOk) {
209
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_RATE_LIMITED', { userId: lower, ip, reason: 'code_per_ip', outcome: 'failure' });
210
+ return res.status(429).json({ ok: false, error: 'rate_limited' });
211
+ }
212
+ const emailOk = recordAndCheckRate(recentEmailRequests, `code:${lower}`, MAX_EMAIL_REQUESTS_PER_EMAIL_PER_10MIN);
213
+ if (!emailOk) {
214
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_RATE_LIMITED', { userId: lower, ip, reason: 'code_per_email', outcome: 'failure' });
215
+ return res.status(429).json({ ok: false, error: 'rate_limited' });
216
+ }
217
+ const pending = await dbService.consumePendingVerificationByCode(lower, (0, email_code_1.hashEmailCode)(lower, codeInput)).catch(() => null);
218
+ if (!pending) {
219
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_CONSUMED', { userId: lower, ip, outcome: 'failure', reason: 'invalid_or_expired' });
220
+ return res.status(401).json({ ok: false, error: 'invalid_or_expired_code' });
221
+ }
222
+ const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
223
+ if (!apiKeyData) {
224
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_CONSUMED', { userId: lower, ip, outcome: 'failure', reason: 'no_account' });
225
+ return res.status(404).json({ ok: false, error: 'no_account' });
226
+ }
227
+ const sessionId = (0, oauth_helpers_1.generateSessionId)();
228
+ await dbService.createAuthSession({
229
+ sessionId,
230
+ userId: lower,
231
+ authMethod: 'email-code',
232
+ userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined,
233
+ ip,
234
+ });
235
+ await (0, audit_log_1.auditLog)('EMAIL_CODE_CONSUMED', { userId: lower, ip, outcome: 'success' });
236
+ await (0, audit_log_1.auditLog)('SESSION_CREATED', { userId: lower, sessionId, surface, outcome: 'success' });
237
+ (0, cookie_service_1.setSessionCookie)(res, sessionId);
238
+ if (surface === 'mobile') {
239
+ return res.status(200).json({ ok: true, mobileCode: issueMobileExchangeCode(apiKeyData.key) });
240
+ }
241
+ return res.status(200).json({ ok: true, redirectTo: requestedRedirect });
242
+ }
243
+ catch (err) {
244
+ console.error('[FRAIM AUTH] /api/auth/email/verify-code error:', err instanceof Error ? err.message : String(err));
245
+ return res.status(400).json({ ok: false, error: 'invalid_code' });
246
+ }
247
+ });
248
+ // Mobile exchange: trade a short-lived code (created by /api/auth/mobile/claim) for an API key.
249
+ // Public — the code itself is the proof of authorization.
250
+ app.post('/api/auth/mobile/exchange', async (req, res) => {
251
+ try {
252
+ pruneMobileCodes();
253
+ const code = String(req.body?.code ?? '').trim();
254
+ if (!code)
255
+ return res.status(400).json({ ok: false, error: 'missing_code' });
256
+ const entry = mobileCodeStore.get(code);
257
+ if (!entry || entry.expiresAt < Date.now()) {
258
+ mobileCodeStore.delete(code);
259
+ return res.status(401).json({ ok: false, error: 'invalid_or_expired_code' });
260
+ }
261
+ mobileCodeStore.delete(code); // one-time use
262
+ return res.status(200).json({ ok: true, apiKey: entry.apiKey });
263
+ }
264
+ catch (err) {
265
+ console.error('[FRAIM AUTH] /api/auth/mobile/exchange error:', err instanceof Error ? err.message : String(err));
266
+ return res.status(500).json({ ok: false });
267
+ }
268
+ });
269
+ }
270
+ function registerProtectedAuthRoutes(app, deps) {
271
+ const { dbService } = deps;
272
+ app.post('/api/auth/logout', async (req, res) => {
273
+ try {
274
+ const sessionToken = (0, cookie_service_1.readSessionCookie)(req) || (0, cookie_service_1.readBearerToken)(req);
275
+ const userId = req.apiKeyData?.userId || null;
276
+ if (sessionToken) {
277
+ await dbService.revokeAuthSession(sessionToken);
278
+ await (0, audit_log_1.auditLog)('LOGOUT', { userId, sessionId: sessionToken, outcome: 'success' });
279
+ }
280
+ (0, cookie_service_1.clearSessionCookie)(res);
281
+ return res.status(200).json({ ok: true });
282
+ }
283
+ catch (err) {
284
+ console.error('[FRAIM AUTH] /api/auth/logout error:', err instanceof Error ? err.message : String(err));
285
+ return res.status(500).json({ ok: false });
286
+ }
287
+ });
288
+ app.post('/api/auth/logout-all', async (req, res) => {
289
+ try {
290
+ const userId = req.apiKeyData?.userId;
291
+ if (!userId)
292
+ return res.status(401).json({ ok: false });
293
+ const currentSessionId = (0, cookie_service_1.readSessionCookie)(req) || (0, cookie_service_1.readBearerToken)(req) || undefined;
294
+ const revokedCount = await dbService.revokeAllAuthSessionsForUser(userId, currentSessionId);
295
+ await (0, audit_log_1.auditLog)('SESSIONS_REVOKED_ALL_OTHERS', { userId, sessionId: currentSessionId, outcome: 'success', revokedCount });
296
+ return res.status(200).json({ ok: true, revokedCount });
297
+ }
298
+ catch (err) {
299
+ console.error('[FRAIM AUTH] /api/auth/logout-all error:', err instanceof Error ? err.message : String(err));
300
+ return res.status(500).json({ ok: false });
301
+ }
302
+ });
303
+ app.get('/api/account/api-key', async (req, res) => {
304
+ try {
305
+ const apiKeyData = req.apiKeyData;
306
+ if (!apiKeyData)
307
+ return res.status(401).json({ ok: false });
308
+ await (0, audit_log_1.auditLog)('KEY_REVEALED', {
309
+ userId: apiKeyData.userId,
310
+ sessionId: req.authSession?.sessionId ?? null,
311
+ outcome: 'success',
312
+ });
313
+ return res.status(200).json({
314
+ ok: true,
315
+ apiKey: apiKeyData.key,
316
+ ...serializeApiKeyLifecycle(apiKeyData),
317
+ });
318
+ }
319
+ catch (err) {
320
+ console.error('[FRAIM AUTH] /api/account/api-key error:', err instanceof Error ? err.message : String(err));
321
+ return res.status(500).json({ ok: false });
322
+ }
323
+ });
324
+ app.post('/api/account/api-key/rotate', async (req, res) => {
325
+ try {
326
+ const apiKeyData = req.apiKeyData;
327
+ if (!apiKeyData)
328
+ return res.status(401).json({ ok: false });
329
+ const { randomBytes } = await Promise.resolve().then(() => __importStar(require('crypto')));
330
+ const newKey = `fraim_${randomBytes(24).toString('hex')}`;
331
+ const ok = await dbService.rotateApiKey(apiKeyData.key, newKey);
332
+ if (!ok)
333
+ return res.status(500).json({ ok: false });
334
+ await (0, audit_log_1.auditLog)('KEY_ROTATED', {
335
+ userId: apiKeyData.userId,
336
+ sessionId: req.authSession?.sessionId ?? null,
337
+ outcome: 'success',
338
+ });
339
+ return res.status(200).json({ ok: true, apiKey: newKey });
340
+ }
341
+ catch (err) {
342
+ console.error('[FRAIM AUTH] /api/account/api-key/rotate error:', err instanceof Error ? err.message : String(err));
343
+ return res.status(500).json({ ok: false });
344
+ }
345
+ });
346
+ app.get('/api/account/sessions', async (req, res) => {
347
+ try {
348
+ const apiKeyData = req.apiKeyData;
349
+ if (!apiKeyData)
350
+ return res.status(401).json({ ok: false });
351
+ const sessions = await dbService.listActiveAuthSessionsForUser(apiKeyData.userId);
352
+ const currentSessionId = req.authSession?.sessionId ?? null;
353
+ return res.status(200).json({
354
+ ok: true,
355
+ currentSessionId,
356
+ sessions: sessions.map(s => ({
357
+ sessionId: s.sessionId,
358
+ authMethod: s.authMethod,
359
+ createdAt: s.createdAt,
360
+ lastSeenAt: s.lastSeenAt,
361
+ expiresAt: s.expiresAt,
362
+ userAgent: s.userAgent,
363
+ ip: s.ip,
364
+ })),
365
+ });
366
+ }
367
+ catch (err) {
368
+ console.error('[FRAIM AUTH] /api/account/sessions error:', err instanceof Error ? err.message : String(err));
369
+ return res.status(500).json({ ok: false });
370
+ }
371
+ });
372
+ app.post('/api/account/sessions/:id/revoke', async (req, res) => {
373
+ try {
374
+ const apiKeyData = req.apiKeyData;
375
+ if (!apiKeyData)
376
+ return res.status(401).json({ ok: false });
377
+ const targetId = String(req.params.id || '');
378
+ const target = await dbService.getAuthSession(targetId);
379
+ if (!target || target.userId !== apiKeyData.userId) {
380
+ return res.status(404).json({ ok: false });
381
+ }
382
+ await dbService.revokeAuthSession(targetId);
383
+ await (0, audit_log_1.auditLog)('SESSION_REVOKED', {
384
+ userId: apiKeyData.userId,
385
+ sessionId: targetId,
386
+ outcome: 'success',
387
+ reason: 'user_revoke',
388
+ });
389
+ return res.status(200).json({ ok: true });
390
+ }
391
+ catch (err) {
392
+ console.error('[FRAIM AUTH] /api/account/sessions/:id/revoke error:', err instanceof Error ? err.message : String(err));
393
+ return res.status(500).json({ ok: false });
394
+ }
395
+ });
396
+ app.post('/api/account/sessions/revoke-all-others', async (req, res) => {
397
+ try {
398
+ const apiKeyData = req.apiKeyData;
399
+ if (!apiKeyData)
400
+ return res.status(401).json({ ok: false });
401
+ const currentSessionId = (0, cookie_service_1.readSessionCookie)(req) || (0, cookie_service_1.readBearerToken)(req) || undefined;
402
+ const revokedCount = await dbService.revokeAllAuthSessionsForUser(apiKeyData.userId, currentSessionId);
403
+ await (0, audit_log_1.auditLog)('SESSIONS_REVOKED_ALL_OTHERS', {
404
+ userId: apiKeyData.userId,
405
+ sessionId: currentSessionId,
406
+ outcome: 'success',
407
+ revokedCount,
408
+ });
409
+ return res.status(200).json({ ok: true, revokedCount });
410
+ }
411
+ catch (err) {
412
+ console.error('[FRAIM AUTH] /api/account/sessions/revoke-all-others error:', err instanceof Error ? err.message : String(err));
413
+ return res.status(500).json({ ok: false });
414
+ }
415
+ });
416
+ app.get('/api/account/activity', async (req, res) => {
417
+ try {
418
+ const apiKeyData = req.apiKeyData;
419
+ if (!apiKeyData)
420
+ return res.status(401).json({ ok: false });
421
+ const days = Math.min(Math.max(parseInt(String(req.query.days ?? '30'), 10) || 30, 1), 90);
422
+ const sinceMs = days * 24 * 60 * 60 * 1000;
423
+ const records = await dbService.listAuditRecordsForUser(apiKeyData.userId, sinceMs, 200);
424
+ return res.status(200).json({
425
+ ok: true,
426
+ days,
427
+ records: records.map(r => ({
428
+ sequence: r.sequence,
429
+ ts: r.ts,
430
+ event: r.event,
431
+ provider: r.provider ?? null,
432
+ surface: r.surface ?? null,
433
+ ip: r.ip ?? null,
434
+ outcome: r.outcome ?? null,
435
+ })),
436
+ });
437
+ }
438
+ catch (err) {
439
+ console.error('[FRAIM AUTH] /api/account/activity error:', err instanceof Error ? err.message : String(err));
440
+ return res.status(500).json({ ok: false });
441
+ }
442
+ });
443
+ app.get('/api/auth/session', async (req, res) => {
444
+ try {
445
+ const apiKeyData = req.apiKeyData;
446
+ const authSession = req.authSession;
447
+ if (!apiKeyData)
448
+ return res.status(401).json({ ok: false });
449
+ const rawMethod = authSession?.authMethod ?? 'api-key';
450
+ // Normalise 'oauth:google' → 'google', 'oauth:github' → 'github', etc.
451
+ const signInMethod = rawMethod.startsWith('oauth:') ? rawMethod.slice(6) : rawMethod;
452
+ // count <= 1 means this is the user's first-ever sign-in (the current session is
453
+ // already persisted when this endpoint is called, so count is 1, not 0).
454
+ let isFirstSignIn = false;
455
+ if (authSession?.userId) {
456
+ const total = await dbService.countTotalAuthSessionsForUser(authSession.userId);
457
+ isFirstSignIn = total <= 1;
458
+ }
459
+ return res.status(200).json({
460
+ ok: true,
461
+ // Flat fields consumed by auth-bootstrap.js in embedded pages
462
+ email: apiKeyData.userId,
463
+ authMethod: signInMethod,
464
+ source: req.authSource ?? 'api-key',
465
+ isFirstSignIn,
466
+ ...serializeApiKeyLifecycle(apiKeyData),
467
+ // Nested user object consumed by public/index.html
468
+ user: {
469
+ email: apiKeyData.userId,
470
+ signInMethod,
471
+ apiKey: apiKeyData.key,
472
+ ...serializeApiKeyLifecycle(apiKeyData),
473
+ },
474
+ });
475
+ }
476
+ catch (err) {
477
+ console.error('[FRAIM AUTH] /api/auth/session error:', err instanceof Error ? err.message : String(err));
478
+ return res.status(500).json({ ok: false });
479
+ }
480
+ });
481
+ // Mobile claim: authenticated endpoint that generates a short-lived code the mobile app
482
+ // can exchange for its API key. Called as the redirect_to target after OAuth
483
+ // so the browser is already authenticated (session cookie set) when it lands here.
484
+ app.get('/api/auth/mobile/claim', async (req, res) => {
485
+ try {
486
+ const apiKeyData = req.apiKeyData;
487
+ if (!apiKeyData)
488
+ return res.redirect('/auth/error.html?reason=invalid');
489
+ const code = issueMobileExchangeCode(apiKeyData.key);
490
+ // Deep-link back to the app. Express can redirect to non-http schemes.
491
+ return res.redirect(`fraim://auth/callback?code=${encodeURIComponent(code)}`);
492
+ }
493
+ catch (err) {
494
+ console.error('[FRAIM AUTH] /api/auth/mobile/claim error:', err instanceof Error ? err.message : String(err));
495
+ return res.redirect('/auth/error.html?reason=invalid');
496
+ }
497
+ });
498
+ }
499
+ /**
500
+ * Test-only helper to reset the in-memory rate-limit state between tests.
501
+ */
502
+ function __resetAuthRouteRateLimitsForTests() {
503
+ recentEmailRequests.clear();
504
+ recentIpRequests.clear();
505
+ }