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.
- package/dist/src/ai-hub/conversation-store.js +4 -0
- package/dist/src/ai-hub/desktop-main.js +8 -0
- package/dist/src/ai-hub/hosts.js +1 -1
- package/dist/src/ai-hub/manager-turns.js +2 -2
- package/dist/src/ai-hub/preferences.js +99 -0
- package/dist/src/ai-hub/server.js +102 -21
- package/dist/src/cli/distribution/marketplace-bundles.js +47 -29
- package/dist/src/fraim/db-service.js +25 -3
- package/dist/src/routes/oauth-routes.js +40 -5
- package/dist/src/services/account-provisioning.js +38 -0
- package/dist/src/services/admin-service.js +11 -37
- package/package.json +1 -1
- package/public/ai-hub/index.html +6 -0
- package/public/ai-hub/script.js +270 -61
- package/public/ai-hub/styles.css +44 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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',
|
|
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,
|
|
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,
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
144
|
-
company: company
|
|
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}
|
|
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
|
|
188
|
-
|
|
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.
|
|
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": {
|
package/public/ai-hub/index.html
CHANGED
|
@@ -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
|
|