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,325 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerOAuthRoutes = registerOAuthRoutes;
4
+ const cookie_service_1 = require("../services/cookie-service");
5
+ const oauth_helpers_1 = require("../services/oauth-helpers");
6
+ const audit_log_1 = require("../services/audit-log");
7
+ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
8
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
9
+ const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
10
+ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
11
+ const GITHUB_USER_EMAILS_URL = 'https://api.github.com/user/emails';
12
+ function getGoogleOAuthConfig() {
13
+ const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID;
14
+ const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET;
15
+ if (!clientId || !clientSecret) {
16
+ throw new Error('GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET must be set');
17
+ }
18
+ return { clientId, clientSecret };
19
+ }
20
+ function getGitHubOAuthConfig() {
21
+ const clientId = process.env.GITHUB_OAUTH_CLIENT_ID;
22
+ const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET;
23
+ if (!clientId || !clientSecret) {
24
+ throw new Error('GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET must be set');
25
+ }
26
+ return { clientId, clientSecret };
27
+ }
28
+ /**
29
+ * Build the base URL used for OAuth redirect_uri and token-exchange.
30
+ *
31
+ * Preference order:
32
+ * 1. FRAIM_PUBLIC_BASE_URL env var (explicit canonical override, recommended in prod)
33
+ * 2. X-Forwarded-Proto + X-Forwarded-Host from the request (set by Azure App Service
34
+ * from the actual hostname the user navigated to — safe because Azure validates
35
+ * that host headers correspond to configured custom domains or the default
36
+ * azurewebsites.net hostname before forwarding the request)
37
+ *
38
+ * Using the request-derived host means the OAuth flow stays on whichever domain
39
+ * the user signed in from, so the fraim_oauth_pending cookie is always in scope
40
+ * for the callback. No hard-coded domain needed.
41
+ */
42
+ function buildBaseUrl(req) {
43
+ const fromEnv = process.env.FRAIM_PUBLIC_BASE_URL;
44
+ if (fromEnv && /^https?:\/\//i.test(fromEnv)) {
45
+ return fromEnv.replace(/\/+$/, '');
46
+ }
47
+ const proto = req.headers['x-forwarded-proto'] || req.protocol || 'https';
48
+ const host = req.headers['x-forwarded-host'] || req.get('host') || 'localhost';
49
+ return `${proto}://${host}`;
50
+ }
51
+ function callbackUrl(req, provider) {
52
+ return `${buildBaseUrl(req)}/api/auth/oauth/${provider}/callback`;
53
+ }
54
+ function decodeJwtPayload(jwt) {
55
+ const parts = jwt.split('.');
56
+ if (parts.length !== 3)
57
+ throw new Error('malformed JWT');
58
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
59
+ const padded = payload + '='.repeat((4 - payload.length % 4) % 4);
60
+ const json = Buffer.from(padded, 'base64').toString('utf8');
61
+ return JSON.parse(json);
62
+ }
63
+ function registerOAuthRoutes(app, deps) {
64
+ const { dbService } = deps;
65
+ const fetchFn = deps.fetch ?? globalThis.fetch;
66
+ const googleCfg = deps.getGoogleConfig ?? getGoogleOAuthConfig;
67
+ const githubCfg = deps.getGitHubConfig ?? getGitHubOAuthConfig;
68
+ // Startup diagnostics — log config presence without exposing secret values.
69
+ console.log('[FRAIM OAuth] startup config check:', {
70
+ FRAIM_PUBLIC_BASE_URL: process.env.FRAIM_PUBLIC_BASE_URL || '(not set — will derive from request host)',
71
+ FRAIM_COOKIE_DOMAIN: process.env.FRAIM_COOKIE_DOMAIN || '(not set — cookie scoped to exact host)',
72
+ GOOGLE_OAUTH_CLIENT_ID: process.env.GOOGLE_OAUTH_CLIENT_ID ? '(set)' : '(NOT SET)',
73
+ GOOGLE_OAUTH_CLIENT_SECRET: process.env.GOOGLE_OAUTH_CLIENT_SECRET ? '(set)' : '(NOT SET)',
74
+ GITHUB_OAUTH_CLIENT_ID: process.env.GITHUB_OAUTH_CLIENT_ID ? '(set)' : '(NOT SET)',
75
+ NODE_ENV: process.env.NODE_ENV || '(not set)',
76
+ });
77
+ // Helper used by both providers to finalise authentication once we have a verified email.
78
+ async function completeSignIn(req, res, verifiedEmail, provider, surface, redirectTo) {
79
+ const lower = verifiedEmail.toLowerCase();
80
+ const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
81
+ if (!apiKeyData) {
82
+ // Sign-in is NOT a sign-up. If the verified email doesn't already match a
83
+ // FRAIM account, route the user to the sign-up flow. Account creation is
84
+ // owned by /api/request-access, not by login. (Round-2 user feedback.)
85
+ await (0, audit_log_1.auditLog)('OAUTH_CALLBACK_SUCCESS', {
86
+ userId: lower,
87
+ provider,
88
+ surface,
89
+ outcome: 'failure',
90
+ reason: 'no_account',
91
+ });
92
+ (0, cookie_service_1.clearOAuthPendingCookie)(res);
93
+ return res.redirect(`/auth/error.html?reason=no_account`);
94
+ }
95
+ const sessionId = (0, oauth_helpers_1.generateSessionId)();
96
+ await dbService.createAuthSession({
97
+ sessionId,
98
+ userId: lower,
99
+ authMethod: provider === 'google' ? 'oauth:google' : 'oauth:github',
100
+ userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined,
101
+ ip: (0, oauth_helpers_1.clientIpFromReq)(req),
102
+ });
103
+ await (0, audit_log_1.auditLog)('OAUTH_CALLBACK_SUCCESS', { userId: lower, sessionId, provider, surface, outcome: 'success' });
104
+ await (0, audit_log_1.auditLog)('SESSION_CREATED', { userId: lower, sessionId, surface, provider, outcome: 'success' });
105
+ (0, cookie_service_1.setSessionCookie)(res, sessionId);
106
+ (0, cookie_service_1.clearOAuthPendingCookie)(res);
107
+ res.redirect(redirectTo);
108
+ }
109
+ app.get('/api/auth/oauth/:provider/start', async (req, res) => {
110
+ const providerRaw = req.params.provider;
111
+ const provider = typeof providerRaw === 'string' ? providerRaw : '';
112
+ if (!(0, oauth_helpers_1.isSupportedProvider)(provider)) {
113
+ return res.status(404).json({ error: 'unsupported_provider' });
114
+ }
115
+ const surface = (0, oauth_helpers_1.pickSurfaceOrDefault)(req.query.surface);
116
+ const redirectTo = (0, oauth_helpers_1.safeRedirectPath)(req.query.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
117
+ try {
118
+ const codeVerifier = (0, oauth_helpers_1.generatePkceVerifier)();
119
+ const stateNonce = (0, oauth_helpers_1.generateStateNonce)();
120
+ const pendingId = (0, oauth_helpers_1.generatePendingOAuthId)();
121
+ await dbService.createPendingOAuth({
122
+ pendingId,
123
+ provider,
124
+ surface,
125
+ codeVerifier,
126
+ stateNonce,
127
+ redirectTo,
128
+ });
129
+ (0, cookie_service_1.setOAuthPendingCookie)(res, pendingId);
130
+ await (0, audit_log_1.auditLog)('OAUTH_START', { provider, surface, ip: (0, oauth_helpers_1.clientIpFromReq)(req), outcome: 'success' });
131
+ if (provider === 'google') {
132
+ const cfg = googleCfg();
133
+ const url = new URL(GOOGLE_AUTH_URL);
134
+ url.searchParams.set('response_type', 'code');
135
+ url.searchParams.set('client_id', cfg.clientId);
136
+ url.searchParams.set('redirect_uri', callbackUrl(req, 'google'));
137
+ url.searchParams.set('scope', 'openid email profile');
138
+ url.searchParams.set('state', stateNonce);
139
+ url.searchParams.set('code_challenge', (0, oauth_helpers_1.pkceChallenge)(codeVerifier));
140
+ url.searchParams.set('code_challenge_method', 'S256');
141
+ url.searchParams.set('access_type', 'online');
142
+ url.searchParams.set('prompt', 'select_account');
143
+ return res.redirect(url.toString());
144
+ }
145
+ else {
146
+ const cfg = githubCfg();
147
+ const url = new URL(GITHUB_AUTH_URL);
148
+ url.searchParams.set('client_id', cfg.clientId);
149
+ url.searchParams.set('redirect_uri', callbackUrl(req, 'github'));
150
+ url.searchParams.set('scope', 'read:user user:email');
151
+ url.searchParams.set('state', stateNonce);
152
+ url.searchParams.set('allow_signup', 'true');
153
+ return res.redirect(url.toString());
154
+ }
155
+ }
156
+ catch (err) {
157
+ console.error(`[FRAIM AUTH] /api/auth/oauth/${provider}/start error:`, err instanceof Error ? err.message : String(err));
158
+ return res.redirect('/auth/error.html?reason=oauth_start_failed');
159
+ }
160
+ });
161
+ app.get('/api/auth/oauth/google/callback', async (req, res) => {
162
+ const errorParam = req.query.error;
163
+ const code = req.query.code;
164
+ const state = req.query.state;
165
+ const pendingId = (0, cookie_service_1.readOAuthPendingCookie)(req);
166
+ try {
167
+ if (errorParam) {
168
+ await (0, audit_log_1.auditLog)('OAUTH_PROVIDER_ERROR', { provider: 'google', reason: errorParam, outcome: 'failure' });
169
+ (0, cookie_service_1.clearOAuthPendingCookie)(res);
170
+ return res.redirect('/?oauth_error=denied');
171
+ }
172
+ if (!code || !state || !pendingId) {
173
+ // Most common prod cause: cookie domain mismatch (sign-in page on domain A, callback on domain B).
174
+ // Check FRAIM_PUBLIC_BASE_URL — it must match the domain the user signed in from.
175
+ console.error('[FRAIM OAuth] google callback missing params:', {
176
+ hasCode: !!code,
177
+ hasState: !!state,
178
+ hasPendingCookie: !!pendingId,
179
+ host: req.get('host'),
180
+ publicBaseUrl: process.env.FRAIM_PUBLIC_BASE_URL || '(not set)',
181
+ });
182
+ await (0, audit_log_1.auditLog)('OAUTH_STATE_MISMATCH', { provider: 'google', reason: 'missing_params', outcome: 'failure' });
183
+ (0, cookie_service_1.clearOAuthPendingCookie)(res);
184
+ return res.redirect('/auth/error.html?reason=oauth_state');
185
+ }
186
+ const pending = await dbService.consumePendingOAuth(pendingId);
187
+ if (!pending || pending.provider !== 'google' || pending.stateNonce !== state) {
188
+ const subReason = !pending ? 'pending_not_found' : pending.provider !== 'google' ? 'provider_mismatch' : 'state_mismatch';
189
+ console.error('[FRAIM OAuth] google callback state mismatch:', {
190
+ subReason,
191
+ hasPending: !!pending,
192
+ host: req.get('host'),
193
+ publicBaseUrl: process.env.FRAIM_PUBLIC_BASE_URL || '(not set)',
194
+ });
195
+ await (0, audit_log_1.auditLog)('OAUTH_STATE_MISMATCH', {
196
+ provider: 'google',
197
+ reason: subReason,
198
+ outcome: 'failure',
199
+ });
200
+ (0, cookie_service_1.clearOAuthPendingCookie)(res);
201
+ return res.redirect('/auth/error.html?reason=oauth_state');
202
+ }
203
+ const cfg = googleCfg();
204
+ const tokenResp = await fetchFn(GOOGLE_TOKEN_URL, {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
207
+ body: new URLSearchParams({
208
+ grant_type: 'authorization_code',
209
+ code,
210
+ code_verifier: pending.codeVerifier,
211
+ client_id: cfg.clientId,
212
+ client_secret: cfg.clientSecret,
213
+ redirect_uri: callbackUrl(req, 'google'),
214
+ }).toString(),
215
+ });
216
+ if (!tokenResp.ok) {
217
+ const text = await tokenResp.text().catch(() => '');
218
+ await (0, audit_log_1.auditLog)('OAUTH_PROVIDER_ERROR', { provider: 'google', reason: `token_${tokenResp.status}`, outcome: 'failure', extra: text.slice(0, 200) });
219
+ return res.redirect('/auth/error.html?reason=oauth_provider');
220
+ }
221
+ const tokenJson = await tokenResp.json();
222
+ if (!tokenJson.id_token) {
223
+ await (0, audit_log_1.auditLog)('OAUTH_PROVIDER_ERROR', { provider: 'google', reason: 'no_id_token', outcome: 'failure' });
224
+ return res.redirect('/auth/error.html?reason=oauth_provider');
225
+ }
226
+ const payload = decodeJwtPayload(tokenJson.id_token);
227
+ if (!payload.email_verified || !(0, oauth_helpers_1.isLikelyValidEmail)(payload.email)) {
228
+ await (0, audit_log_1.auditLog)('OAUTH_UNVERIFIED_EMAIL', { provider: 'google', outcome: 'failure' });
229
+ return res.redirect('/auth/error.html?reason=unverified_email');
230
+ }
231
+ await completeSignIn(req, res, payload.email, 'google', pending.surface, pending.redirectTo);
232
+ }
233
+ catch (err) {
234
+ console.error('[FRAIM AUTH] /api/auth/oauth/google/callback error:', err instanceof Error ? err.message : String(err));
235
+ return res.redirect('/auth/error.html?reason=oauth_provider');
236
+ }
237
+ });
238
+ app.get('/api/auth/oauth/github/callback', async (req, res) => {
239
+ const errorParam = req.query.error;
240
+ const code = req.query.code;
241
+ const state = req.query.state;
242
+ const pendingId = (0, cookie_service_1.readOAuthPendingCookie)(req);
243
+ try {
244
+ if (errorParam) {
245
+ await (0, audit_log_1.auditLog)('OAUTH_PROVIDER_ERROR', { provider: 'github', reason: errorParam, outcome: 'failure' });
246
+ (0, cookie_service_1.clearOAuthPendingCookie)(res);
247
+ return res.redirect('/?oauth_error=denied');
248
+ }
249
+ if (!code || !state || !pendingId) {
250
+ console.error('[FRAIM OAuth] github callback missing params:', {
251
+ hasCode: !!code,
252
+ hasState: !!state,
253
+ hasPendingCookie: !!pendingId,
254
+ host: req.get('host'),
255
+ publicBaseUrl: process.env.FRAIM_PUBLIC_BASE_URL || '(not set)',
256
+ });
257
+ await (0, audit_log_1.auditLog)('OAUTH_STATE_MISMATCH', { provider: 'github', reason: 'missing_params', outcome: 'failure' });
258
+ (0, cookie_service_1.clearOAuthPendingCookie)(res);
259
+ return res.redirect('/auth/error.html?reason=oauth_state');
260
+ }
261
+ const pending = await dbService.consumePendingOAuth(pendingId);
262
+ if (!pending || pending.provider !== 'github' || pending.stateNonce !== state) {
263
+ const subReason = !pending ? 'pending_not_found' : pending.provider !== 'github' ? 'provider_mismatch' : 'state_mismatch';
264
+ console.error('[FRAIM OAuth] github callback state mismatch:', {
265
+ subReason,
266
+ hasPending: !!pending,
267
+ host: req.get('host'),
268
+ publicBaseUrl: process.env.FRAIM_PUBLIC_BASE_URL || '(not set)',
269
+ });
270
+ await (0, audit_log_1.auditLog)('OAUTH_STATE_MISMATCH', {
271
+ provider: 'github',
272
+ reason: subReason,
273
+ outcome: 'failure',
274
+ });
275
+ (0, cookie_service_1.clearOAuthPendingCookie)(res);
276
+ return res.redirect('/auth/error.html?reason=oauth_state');
277
+ }
278
+ const cfg = githubCfg();
279
+ const tokenResp = await fetchFn(GITHUB_TOKEN_URL, {
280
+ method: 'POST',
281
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
282
+ body: new URLSearchParams({
283
+ client_id: cfg.clientId,
284
+ client_secret: cfg.clientSecret,
285
+ code,
286
+ redirect_uri: callbackUrl(req, 'github'),
287
+ }).toString(),
288
+ });
289
+ if (!tokenResp.ok) {
290
+ const text = await tokenResp.text().catch(() => '');
291
+ await (0, audit_log_1.auditLog)('OAUTH_PROVIDER_ERROR', { provider: 'github', reason: `token_${tokenResp.status}`, outcome: 'failure', extra: text.slice(0, 200) });
292
+ return res.redirect('/auth/error.html?reason=oauth_provider');
293
+ }
294
+ const tokenJson = await tokenResp.json();
295
+ if (!tokenJson.access_token) {
296
+ await (0, audit_log_1.auditLog)('OAUTH_PROVIDER_ERROR', { provider: 'github', reason: tokenJson.error ?? 'no_access_token', outcome: 'failure' });
297
+ return res.redirect('/auth/error.html?reason=oauth_provider');
298
+ }
299
+ const emailsResp = await fetchFn(GITHUB_USER_EMAILS_URL, {
300
+ headers: {
301
+ Accept: 'application/vnd.github+json',
302
+ Authorization: `Bearer ${tokenJson.access_token}`,
303
+ 'User-Agent': 'fraim-server',
304
+ 'X-GitHub-Api-Version': '2022-11-28',
305
+ },
306
+ });
307
+ if (!emailsResp.ok) {
308
+ await (0, audit_log_1.auditLog)('OAUTH_PROVIDER_ERROR', { provider: 'github', reason: `emails_${emailsResp.status}`, outcome: 'failure' });
309
+ return res.redirect('/auth/error.html?reason=oauth_provider');
310
+ }
311
+ const emails = await emailsResp.json();
312
+ const primary = emails.find(e => e.primary && e.verified);
313
+ const verifiedEmail = primary?.email ?? emails.find(e => e.verified)?.email;
314
+ if (!verifiedEmail || !(0, oauth_helpers_1.isLikelyValidEmail)(verifiedEmail)) {
315
+ await (0, audit_log_1.auditLog)('OAUTH_UNVERIFIED_EMAIL', { provider: 'github', outcome: 'failure' });
316
+ return res.redirect('/auth/error.html?reason=unverified_email');
317
+ }
318
+ await completeSignIn(req, res, verifiedEmail, 'github', pending.surface, pending.redirectTo);
319
+ }
320
+ catch (err) {
321
+ console.error('[FRAIM AUTH] /api/auth/oauth/github/callback error:', err instanceof Error ? err.message : String(err));
322
+ return res.redirect('/auth/error.html?reason=oauth_provider');
323
+ }
324
+ });
325
+ }
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerPaymentRoutes = registerPaymentRoutes;
4
+ exports.registerAdminPaymentRoutes = registerAdminPaymentRoutes;
5
+ const create_session_1 = require("../api/payment/create-session");
6
+ const session_details_1 = require("../api/payment/session-details");
7
+ const dashboard_link_1 = require("../api/payment/dashboard-link");
8
+ const webhook_1 = require("../api/payment/webhook");
9
+ const contact_1 = require("../api/sales/contact");
10
+ const payments_1 = require("../api/admin/payments");
11
+ const sales_leads_1 = require("../api/admin/sales-leads");
12
+ const get_config_1 = require("../api/pricing/get-config");
13
+ const rate_limit_1 = require("../middleware/rate-limit");
14
+ /**
15
+ * Register all payment-related routes
16
+ * These routes must be registered BEFORE auth middleware
17
+ */
18
+ function registerPaymentRoutes(app, getPaymentRepo, dbService) {
19
+ // Per-route rate limiters. Webhooks are intentionally NOT rate-limited at
20
+ // the application layer because Stripe controls delivery and retry semantics.
21
+ const publicLimiter = (0, rate_limit_1.publicApiRateLimiter)();
22
+ const sensitiveLimiter = (0, rate_limit_1.sensitiveRateLimiter)();
23
+ // Pricing config endpoint (public, no auth required)
24
+ app.get('/api/pricing/config', publicLimiter, async (req, res) => {
25
+ await (0, get_config_1.getPricingConfig)(req, res);
26
+ });
27
+ // Payment API endpoints (public, no auth required)
28
+ app.post('/api/payment/create-session', sensitiveLimiter, async (req, res) => {
29
+ const paymentRepo = getPaymentRepo();
30
+ if (!paymentRepo) {
31
+ return res.status(500).json({ error: 'Payment system not initialized' });
32
+ }
33
+ await (0, create_session_1.createCheckoutSession)(req, res, paymentRepo, dbService);
34
+ });
35
+ // Payment success page — fetch Stripe session details by session_id
36
+ app.get('/api/payment/session-details', publicLimiter, async (req, res) => {
37
+ await (0, session_details_1.getSessionDetails)(req, res);
38
+ });
39
+ // Checkout success page: provide immediate dashboard URL (no email wait)
40
+ app.get('/api/payment/dashboard-link', publicLimiter, async (req, res) => {
41
+ await (0, dashboard_link_1.getDashboardLinkFromCheckoutSession)(req, res, dbService);
42
+ });
43
+ // Stripe webhook endpoint (raw body required for signature verification)
44
+ // Intentionally NOT rate-limited; Stripe handles its own delivery semantics.
45
+ app.post('/api/payment/webhook', async (req, res) => {
46
+ const paymentRepo = getPaymentRepo();
47
+ if (!paymentRepo) {
48
+ return res.status(500).json({ error: 'Payment system not initialized' });
49
+ }
50
+ await (0, webhook_1.handleStripeWebhook)(req, res, paymentRepo, dbService);
51
+ });
52
+ // Sales inquiry endpoint (public, no auth required)
53
+ app.post('/api/sales/contact', sensitiveLimiter, async (req, res) => {
54
+ await (0, contact_1.createSalesLead)(req, res, dbService);
55
+ });
56
+ // Payment bypass endpoint for non-production testing only.
57
+ // Two layers of defence:
58
+ // 1. Route returns 404 entirely when NODE_ENV === 'production'.
59
+ // 2. PAYMENT_BYPASS_CODE must be explicitly set in the environment; there is no
60
+ // hardcoded fallback. If it is unset, the route returns 503.
61
+ app.post('/api/payment/bypass', sensitiveLimiter, async (req, res) => {
62
+ if (process.env.NODE_ENV === 'production') {
63
+ return res.status(404).json({ error: 'Not found' });
64
+ }
65
+ const BYPASS_CODE = process.env.PAYMENT_BYPASS_CODE;
66
+ if (!BYPASS_CODE) {
67
+ console.error('[FRAIM] /api/payment/bypass invoked but PAYMENT_BYPASS_CODE is not configured');
68
+ return res.status(503).json({ error: 'Payment bypass is not configured on this server' });
69
+ }
70
+ const { code, apiKey, daysUntilExpiry } = req.body;
71
+ if (code !== BYPASS_CODE) {
72
+ return res.status(403).json({ error: 'Invalid bypass code' });
73
+ }
74
+ if (!apiKey) {
75
+ return res.status(400).json({ error: 'apiKey is required' });
76
+ }
77
+ const existingKey = await dbService.getApiKeyByKey(apiKey);
78
+ if (!existingKey) {
79
+ return res.status(404).json({ error: 'API key not found' });
80
+ }
81
+ // Cap at 365 days. Defense in depth: even with the bypass code, do not let a
82
+ // request grant a multi-year subscription to an account.
83
+ const MAX_BYPASS_DAYS = 365;
84
+ const requestedDays = parseInt(daysUntilExpiry) || 30;
85
+ const days = Math.max(1, Math.min(requestedDays, MAX_BYPASS_DAYS));
86
+ const newExpiry = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
87
+ await dbService.updateApiKey(apiKey, {
88
+ tier: 'paid-subscription',
89
+ status: 'active',
90
+ expiresAt: newExpiry,
91
+ currentPeriodEnd: newExpiry,
92
+ stripeCustomerId: 'bypass_test_customer',
93
+ stripeSubscriptionId: 'bypass_test_subscription'
94
+ });
95
+ console.log(`[FRAIM] payment_bypass activated`, { userId: existingKey.userId, expiresAt: newExpiry.toISOString() });
96
+ return res.json({
97
+ success: true,
98
+ message: `Key upgraded to paid-subscription, expires in ${days} days`,
99
+ expiresAt: newExpiry.toISOString(),
100
+ tier: 'paid-subscription'
101
+ });
102
+ });
103
+ }
104
+ /**
105
+ * Register admin payment routes
106
+ * These routes require auth middleware to be applied first
107
+ */
108
+ function registerAdminPaymentRoutes(app, getPaymentRepo, dbService) {
109
+ // Admin endpoints (require auth)
110
+ app.get('/admin/payments', async (req, res) => {
111
+ const paymentRepo = getPaymentRepo();
112
+ if (!paymentRepo) {
113
+ return res.status(500).json({ error: 'Payment system not initialized' });
114
+ }
115
+ await (0, payments_1.listPayments)(req, res, paymentRepo);
116
+ });
117
+ app.get('/admin/sales-leads', async (req, res) => {
118
+ await (0, sales_leads_1.listSalesLeads)(req, res, dbService);
119
+ });
120
+ // ── Partner Discount CRUD ─────────────────────────────────────────────────
121
+ /** List all partner discount entries */
122
+ app.get('/admin/partner-discounts', async (req, res) => {
123
+ try {
124
+ const entries = await dbService.listPartnerDiscounts();
125
+ res.json(entries);
126
+ }
127
+ catch (error) {
128
+ res.status(500).json({ error: 'Failed to list partner discounts', details: error.message });
129
+ }
130
+ });
131
+ /** Create a new partner discount entry */
132
+ app.post('/admin/partner-discounts', async (req, res) => {
133
+ try {
134
+ const { type, match, couponId, company } = req.body;
135
+ if (!type || !['email', 'domain'].includes(type)) {
136
+ return res.status(400).json({ error: 'type must be "email" or "domain"' });
137
+ }
138
+ if (!match || typeof match !== 'string' || !match.trim()) {
139
+ return res.status(400).json({ error: 'match is required (email address or domain)' });
140
+ }
141
+ if (!couponId || typeof couponId !== 'string' || !couponId.trim()) {
142
+ return res.status(400).json({ error: 'couponId is required (Stripe coupon ID)' });
143
+ }
144
+ const entry = await dbService.createPartnerDiscount({
145
+ type,
146
+ match: match.trim().toLowerCase(),
147
+ couponId: couponId.trim(),
148
+ company: company?.trim() || undefined,
149
+ active: true,
150
+ createdAt: new Date(),
151
+ useCount: 0,
152
+ });
153
+ res.status(201).json(entry);
154
+ }
155
+ catch (error) {
156
+ if (error.code === 11000) {
157
+ return res.status(409).json({ error: 'A discount entry for that type+match already exists' });
158
+ }
159
+ res.status(500).json({ error: 'Failed to create partner discount', details: error.message });
160
+ }
161
+ });
162
+ /** Update an existing partner discount entry (active, couponId, company) */
163
+ app.patch('/admin/partner-discounts/:id', async (req, res) => {
164
+ try {
165
+ const id = req.params.id;
166
+ const { active, couponId, company } = req.body;
167
+ const update = {};
168
+ if (typeof active === 'boolean')
169
+ update.active = active;
170
+ if (typeof couponId === 'string' && couponId.trim())
171
+ update.couponId = couponId.trim();
172
+ if (typeof company === 'string')
173
+ update.company = company.trim() || undefined;
174
+ if (Object.keys(update).length === 0) {
175
+ return res.status(400).json({ error: 'No valid fields to update (active, couponId, company)' });
176
+ }
177
+ const updated = await dbService.updatePartnerDiscount(id, update);
178
+ if (!updated)
179
+ return res.status(404).json({ error: 'Partner discount not found' });
180
+ res.json({ success: true });
181
+ }
182
+ catch (error) {
183
+ res.status(500).json({ error: 'Failed to update partner discount', details: error.message });
184
+ }
185
+ });
186
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ /**
3
+ * GET /api/personas/catalog
4
+ *
5
+ * Public, read-only endpoint that returns the full persona catalog for
6
+ * fraim-pro/index.html and fraim-pro/pricing.html to hydrate their grids.
7
+ *
8
+ * Auth: none — added to publicPrefixes in src/middleware/auth.ts.
9
+ *
10
+ * Response shape: PersonaCatalogResponse (see types below).
11
+ *
12
+ * Issue #545: centralizes emoji, gradient, blurb, and price display in
13
+ * PERSONA_HIRE_CATALOG so the HTML surfaces are derived views, not sources
14
+ * of truth.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.formatPrice = formatPrice;
18
+ exports.buildPersonaCatalog = buildPersonaCatalog;
19
+ exports.registerPersonaCatalogRoutes = registerPersonaCatalogRoutes;
20
+ const fs_1 = require("fs");
21
+ const path_1 = require("path");
22
+ const persona_hiring_1 = require("../config/persona-hiring");
23
+ const persona_capability_bundles_1 = require("../config/persona-capability-bundles");
24
+ const portfolio_slug_overrides_1 = require("../config/portfolio-slug-overrides");
25
+ /**
26
+ * Format a cents integer to a dollar string, dropping trailing .00.
27
+ *
28
+ * Examples:
29
+ * 990 -> "$9.90"
30
+ * 4990 -> "$49.90"
31
+ * 24900 -> "$249"
32
+ * 100 -> "$1"
33
+ *
34
+ * Exported so persona-capability-bundles.ts can reuse the same function.
35
+ */
36
+ function formatPrice(cents) {
37
+ return `$${(cents / 100).toFixed(2).replace(/\.00$/, '')}`;
38
+ }
39
+ /**
40
+ * Build the catalog array from PERSONA_HIRE_CATALOG + PERSONA_CAPABILITY_BUNDLES.
41
+ *
42
+ * Exported for unit testing without requiring an HTTP context.
43
+ */
44
+ function buildPersonaCatalog() {
45
+ const ROOT = process.cwd();
46
+ return Object.entries(persona_hiring_1.PERSONA_HIRE_CATALOG).map(([key, persona]) => {
47
+ const hireKey = key;
48
+ const slug = portfolio_slug_overrides_1.PORTFOLIO_SLUG_OVERRIDES[key] ?? key;
49
+ const hasPortfolioPage = (0, fs_1.existsSync)((0, path_1.join)(ROOT, 'public', 'portfolio', slug + '.html'));
50
+ const bundle = persona_capability_bundles_1.PERSONA_CAPABILITY_BUNDLES[hireKey];
51
+ const sampleJobs = bundle?.catalogMetadata?.sampleJobs ?? [];
52
+ return {
53
+ key,
54
+ displayName: persona.displayName,
55
+ role: persona.role,
56
+ emoji: persona.emoji,
57
+ gradient: persona.gradient,
58
+ blurb: persona.blurb,
59
+ avatarUrl: (0, persona_hiring_1.buildPersonaAvatarUrl)(hireKey),
60
+ avatarBackgroundColor: persona_hiring_1.PERSONA_AVATAR_CATALOG[hireKey].bg,
61
+ jobPriceCents: persona.jobPriceCents,
62
+ fulltimePriceCents: persona.fulltimePriceCents,
63
+ portfolioSlug: slug,
64
+ hasPortfolioPage,
65
+ sampleJobs,
66
+ };
67
+ });
68
+ }
69
+ /**
70
+ * Register the persona catalog route.
71
+ * Must be called BEFORE auth middleware (it is public — no key required).
72
+ */
73
+ function registerPersonaCatalogRoutes(app) {
74
+ app.get('/api/personas/catalog', (req, res) => {
75
+ try {
76
+ const personas = buildPersonaCatalog();
77
+ res.json({ personas });
78
+ }
79
+ catch (err) {
80
+ console.error('[persona-catalog] Error building catalog:', err);
81
+ res.status(500).json({ error: 'Failed to build persona catalog' });
82
+ }
83
+ });
84
+ }