fraim-framework 2.0.182 → 2.0.184

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.
@@ -74,6 +74,17 @@ function normalizeLabels(labels) {
74
74
  function escapeRegex(value) {
75
75
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
76
  }
77
+ /**
78
+ * Ordering for persona entitlements, applied in memory. Azure Cosmos DB (Mongo API)
79
+ * rejects an order-by on these fields without a composite index, so the sort must not
80
+ * be pushed into the query. Mirrors the former `.sort({ personaKey: 1, hireMode: 1 })`.
81
+ */
82
+ function comparePersonaEntitlements(a, b) {
83
+ const byKey = (a.personaKey || '').localeCompare(b.personaKey || '');
84
+ if (byKey !== 0)
85
+ return byKey;
86
+ return (a.hireMode || '').localeCompare(b.hireMode || '');
87
+ }
77
88
  function buildLabelMatch(label) {
78
89
  return { labels: { $regex: `^${escapeRegex(label)}$`, $options: 'i' } };
79
90
  }
@@ -728,12 +739,21 @@ class FraimDbService {
728
739
  if (activeOnly) {
729
740
  query.status = 'active';
730
741
  }
731
- return await this.personaEntitlementsCollection.find(query).sort({ personaKey: 1, hireMode: 1 }).toArray();
742
+ // Sort in memory, not in the query: Azure Cosmos DB (Mongo API) rejects an
743
+ // order-by on { personaKey, hireMode } with BadRequest 400 unless a matching
744
+ // composite index exists, and the initializeIndexes() attempt to create one
745
+ // is swallowed on Cosmos. A throw here bubbles up into getWorkspacePersonaState
746
+ // → the AI Hub silently falls back to ALL personas locked (no hired employees,
747
+ // empty manager team, empty project tree). Sorting client-side is index-free.
748
+ const rows = await this.personaEntitlementsCollection.find(query).toArray();
749
+ return rows.sort(comparePersonaEntitlements);
732
750
  }
733
751
  async listPersonaEntitlementsByStripeCustomerId(customerId) {
734
752
  if (!this.personaEntitlementsCollection)
735
753
  throw new Error('DB not connected');
736
- return await this.personaEntitlementsCollection.find({ stripeCustomerId: customerId }).sort({ updatedAt: -1 }).toArray();
754
+ // In-memory sort see getPersonaEntitlementsByWorkspaceId (Cosmos order-by).
755
+ const rows = await this.personaEntitlementsCollection.find({ stripeCustomerId: customerId }).toArray();
756
+ return rows.sort((a, b) => (b.updatedAt?.getTime() ?? 0) - (a.updatedAt?.getTime() ?? 0));
737
757
  }
738
758
  async getPersonaEntitlementsByUserId(userId, activeOnly = true) {
739
759
  if (!this.personaEntitlementsCollection)
@@ -742,7 +762,9 @@ class FraimDbService {
742
762
  if (activeOnly) {
743
763
  query.status = 'active';
744
764
  }
745
- return await this.personaEntitlementsCollection.find(query).sort({ personaKey: 1, hireMode: 1 }).toArray();
765
+ // In-memory sort see getPersonaEntitlementsByWorkspaceId (Cosmos order-by).
766
+ const rows = await this.personaEntitlementsCollection.find(query).toArray();
767
+ return rows.sort(comparePersonaEntitlements);
746
768
  }
747
769
  async upsertPersonaEntitlement(entitlement) {
748
770
  if (!this.personaEntitlementsCollection)
@@ -4,6 +4,16 @@ exports.registerOAuthRoutes = registerOAuthRoutes;
4
4
  const cookie_service_1 = require("../services/cookie-service");
5
5
  const oauth_helpers_1 = require("../services/oauth-helpers");
6
6
  const audit_log_1 = require("../services/audit-log");
7
+ const account_provisioning_1 = require("../services/account-provisioning");
8
+ const rate_limit_1 = require("../middleware/rate-limit");
9
+ function pickIntent(value) {
10
+ return value === 'signup' ? 'signup' : 'signin';
11
+ }
12
+ // Issue #691: intent=signup makes /start reachable as an account-creation path
13
+ // (see completeSignIn below), the same sensitive business flow /api/request-access
14
+ // already rate-limits. Apply the same limiter here, scoped to signup attempts only —
15
+ // the pre-existing sign-in path (no intent) is left at its original, unlimited behavior.
16
+ const signupRateLimiter = (0, rate_limit_1.sensitiveRateLimiter)();
7
17
  const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
8
18
  const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
9
19
  const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
@@ -75,13 +85,17 @@ function registerOAuthRoutes(app, deps) {
75
85
  NODE_ENV: process.env.NODE_ENV || '(not set)',
76
86
  });
77
87
  // Helper used by both providers to finalise authentication once we have a verified email.
78
- async function completeSignIn(req, res, verifiedEmail, provider, surface, redirectTo) {
88
+ async function completeSignIn(req, res, params) {
89
+ const { verifiedEmail, provider, surface, redirectTo, intent } = params;
79
90
  const lower = verifiedEmail.toLowerCase();
80
91
  const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
81
- if (!apiKeyData) {
92
+ if (!apiKeyData && intent !== 'signup') {
82
93
  // Sign-in is NOT a sign-up. If the verified email doesn't already match a
83
94
  // FRAIM account, route the user to the sign-up flow. Account creation is
84
95
  // owned by /api/request-access, not by login. (Round-2 user feedback.)
96
+ // Issue #691: this only applies to the sign-in surface (intent !== 'signup').
97
+ // The dedicated signup surface uses intent='signup' to provision an account
98
+ // below instead of bouncing to no_account.
85
99
  await (0, audit_log_1.auditLog)('OAUTH_CALLBACK_SUCCESS', {
86
100
  userId: lower,
87
101
  provider,
@@ -92,6 +106,9 @@ function registerOAuthRoutes(app, deps) {
92
106
  (0, cookie_service_1.clearOAuthPendingCookie)(res);
93
107
  return res.redirect(`/auth/error.html?reason=no_account`);
94
108
  }
109
+ if (!apiKeyData && intent === 'signup') {
110
+ await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
111
+ }
95
112
  const sessionId = (0, oauth_helpers_1.generateSessionId)();
96
113
  await dbService.createAuthSession({
97
114
  sessionId,
@@ -106,13 +123,18 @@ function registerOAuthRoutes(app, deps) {
106
123
  (0, cookie_service_1.clearOAuthPendingCookie)(res);
107
124
  res.redirect(redirectTo);
108
125
  }
109
- app.get('/api/auth/oauth/:provider/start', async (req, res) => {
126
+ app.get('/api/auth/oauth/:provider/start', (req, res, next) => {
127
+ if (pickIntent(req.query.intent) === 'signup')
128
+ return signupRateLimiter(req, res, next);
129
+ next();
130
+ }, async (req, res) => {
110
131
  const providerRaw = req.params.provider;
111
132
  const provider = typeof providerRaw === 'string' ? providerRaw : '';
112
133
  if (!(0, oauth_helpers_1.isSupportedProvider)(provider)) {
113
134
  return res.status(404).json({ error: 'unsupported_provider' });
114
135
  }
115
136
  const surface = (0, oauth_helpers_1.pickSurfaceOrDefault)(req.query.surface);
137
+ const intent = pickIntent(req.query.intent);
116
138
  const redirectTo = (0, oauth_helpers_1.safeRedirectPath)(req.query.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
117
139
  try {
118
140
  const codeVerifier = (0, oauth_helpers_1.generatePkceVerifier)();
@@ -122,6 +144,7 @@ function registerOAuthRoutes(app, deps) {
122
144
  pendingId,
123
145
  provider,
124
146
  surface,
147
+ intent,
125
148
  codeVerifier,
126
149
  stateNonce,
127
150
  redirectTo,
@@ -228,7 +251,13 @@ function registerOAuthRoutes(app, deps) {
228
251
  await (0, audit_log_1.auditLog)('OAUTH_UNVERIFIED_EMAIL', { provider: 'google', outcome: 'failure' });
229
252
  return res.redirect('/auth/error.html?reason=unverified_email');
230
253
  }
231
- await completeSignIn(req, res, payload.email, 'google', pending.surface, pending.redirectTo);
254
+ await completeSignIn(req, res, {
255
+ verifiedEmail: payload.email,
256
+ provider: 'google',
257
+ surface: pending.surface,
258
+ redirectTo: pending.redirectTo,
259
+ intent: pickIntent(pending.intent),
260
+ });
232
261
  }
233
262
  catch (err) {
234
263
  console.error('[FRAIM AUTH] /api/auth/oauth/google/callback error:', err instanceof Error ? err.message : String(err));
@@ -315,7 +344,13 @@ function registerOAuthRoutes(app, deps) {
315
344
  await (0, audit_log_1.auditLog)('OAUTH_UNVERIFIED_EMAIL', { provider: 'github', outcome: 'failure' });
316
345
  return res.redirect('/auth/error.html?reason=unverified_email');
317
346
  }
318
- await completeSignIn(req, res, verifiedEmail, 'github', pending.surface, pending.redirectTo);
347
+ await completeSignIn(req, res, {
348
+ verifiedEmail,
349
+ provider: 'github',
350
+ surface: pending.surface,
351
+ redirectTo: pending.redirectTo,
352
+ intent: pickIntent(pending.intent),
353
+ });
319
354
  }
320
355
  catch (err) {
321
356
  console.error('[FRAIM AUTH] /api/auth/oauth/github/callback error:', err instanceof Error ? err.message : String(err));
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ensureTrialApiKey = ensureTrialApiKey;
4
+ const feature_flags_1 = require("../config/feature-flags");
5
+ const TRIAL_DURATION_MS = 14 * 24 * 60 * 60 * 1000;
6
+ /**
7
+ * Provision a 14-day trial FraimApiKey for `emailLower` if one does not already
8
+ * exist; otherwise return the existing key. Shared by the email+code signup
9
+ * flow (`AdminService.verifyRequestAccessCode`) and the OAuth signup-intent
10
+ * flow (`oauth-routes.ts`) so account creation has exactly one implementation.
11
+ * Issue #691.
12
+ */
13
+ async function ensureTrialApiKey(dbService, emailLower) {
14
+ const existingKey = await dbService.getApiKeyByUserId(emailLower, false);
15
+ if (existingKey)
16
+ return existingKey.key;
17
+ const orgId = 'default';
18
+ const apiKey = dbService.generateApiKey(emailLower, orgId);
19
+ const trialExpiresAt = new Date(Date.now() + TRIAL_DURATION_MS);
20
+ await dbService.createApiKey({
21
+ key: apiKey,
22
+ userId: emailLower,
23
+ orgId,
24
+ tier: 'trial',
25
+ status: 'active',
26
+ expiresAt: trialExpiresAt,
27
+ stripeCustomerId: null,
28
+ stripeSubscriptionId: null,
29
+ currentPeriodEnd: null,
30
+ cancelAt: null,
31
+ suspendedAt: null,
32
+ suspensionReason: null,
33
+ lastUsedAt: null,
34
+ apiCallCount: 0,
35
+ personaSystemActive: (0, feature_flags_1.isPersonaEntitlementsEnabled)() || undefined
36
+ });
37
+ return apiKey;
38
+ }
@@ -7,6 +7,7 @@ const email_service_1 = require("./email-service");
7
7
  const dashboard_access_1 = require("./dashboard-access");
8
8
  const feature_flags_1 = require("../config/feature-flags");
9
9
  const email_code_1 = require("./email-code");
10
+ const account_provisioning_1 = require("./account-provisioning");
10
11
  class AdminService {
11
12
  constructor(dbService) {
12
13
  this.dbService = dbService;
@@ -130,8 +131,11 @@ class AdminService {
130
131
  */
131
132
  async requestAccess(body, req) {
132
133
  const { email, name, company, useCase } = body;
133
- if (!email || !name || !company) {
134
- throw new Error('Email, name, and company are required');
134
+ // Issue #691: signup is now email + verification code only. Name/company
135
+ // are optional lead-capture fields (createWebsiteSignup below already
136
+ // tolerates missing values); the trial key itself never required them.
137
+ if (!email) {
138
+ throw new Error('Email is required');
135
139
  }
136
140
  if (!(0, request_utils_1.validateEmail)(email))
137
141
  throw new Error('Invalid email address');
@@ -140,8 +144,8 @@ class AdminService {
140
144
  const { ip: ipAddress, userAgent } = (0, request_utils_1.getRequestMeta)(req);
141
145
  await this.dbService.createWebsiteSignup({
142
146
  email: emailLower,
143
- name: name.trim(),
144
- company: company.trim(),
147
+ name: name?.trim() || '',
148
+ company: company?.trim() || '',
145
149
  useCase: useCase?.trim(),
146
150
  source: 'request-access',
147
151
  timestamp: new Date(),
@@ -166,7 +170,7 @@ class AdminService {
166
170
  });
167
171
  const emailService = new email_service_1.EmailService();
168
172
  const baseUrl = process.env.BASE_URL || 'https://fraimworks.ai';
169
- await emailService.sendEmailCode(emailLower, `${baseUrl}/get-started`, verificationCode, { purpose: 'request-access' });
173
+ await emailService.sendEmailCode(emailLower, `${baseUrl}/?email=${encodeURIComponent(emailLower)}`, verificationCode, { purpose: 'request-access' });
170
174
  console.log('[FRAIM] request_access email_code_sent', { userId: emailLower });
171
175
  return {
172
176
  success: true,
@@ -184,38 +188,8 @@ class AdminService {
184
188
  if (!verification) {
185
189
  return { success: false, error: 'Invalid or expired verification code' };
186
190
  }
187
- const orgId = 'default';
188
- const existingKey = await this.dbService.getApiKeyByUserId(emailLower, false);
189
- let apiKey;
190
- if (existingKey) {
191
- apiKey = existingKey.key;
192
- console.log('[FRAIM] verify_email_code existing_key_found', { userId: emailLower });
193
- }
194
- else {
195
- apiKey = this.dbService.generateApiKey(emailLower, orgId);
196
- const trialExpiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
197
- await this.dbService.createApiKey({
198
- key: apiKey,
199
- userId: emailLower,
200
- orgId,
201
- tier: 'trial',
202
- status: 'active',
203
- expiresAt: trialExpiresAt,
204
- stripeCustomerId: null,
205
- stripeSubscriptionId: null,
206
- currentPeriodEnd: null,
207
- cancelAt: null,
208
- suspendedAt: null,
209
- suspensionReason: null,
210
- lastUsedAt: null,
211
- apiCallCount: 0,
212
- personaSystemActive: (0, feature_flags_1.isPersonaEntitlementsEnabled)() || undefined
213
- });
214
- console.log('[FRAIM] verify_email_code trial_key_created', {
215
- userId: emailLower,
216
- expiresAt: trialExpiresAt.toISOString()
217
- });
218
- }
191
+ const apiKey = await (0, account_provisioning_1.ensureTrialApiKey)(this.dbService, emailLower);
192
+ console.log('[FRAIM] verify_email_code key_resolved', { userId: emailLower });
219
193
  const access = await (0, dashboard_access_1.createDashboardAccessLink)(this.dbService, emailLower, apiKey);
220
194
  console.log('[FRAIM] verify_email_code verification_completed', { userId: emailLower });
221
195
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.182",
3
+ "version": "2.0.184",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -428,6 +428,12 @@
428
428
  </div>
429
429
 
430
430
  </div><!-- /.workspace-conv -->
431
+ <div class="proj-switch-mask" id="proj-switch-mask" role="status" aria-live="polite" aria-hidden="true">
432
+ <div class="proj-switch-panel">
433
+ <span class="proj-switch-spinner" aria-hidden="true"></span>
434
+ <span id="proj-switch-label">Loading project...</span>
435
+ </div>
436
+ </div>
431
437
  </div><!-- /#proj-workspace -->
432
438
  </section><!-- /#area-projects -->
433
439