fraim-framework 2.0.189 → 2.0.191

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.
@@ -62,6 +62,55 @@ function isTrustedInAppNavigation(targetUrl, hubUrl) {
62
62
  return false;
63
63
  }
64
64
  }
65
+ function connectedSurfaceParam(value) {
66
+ return value === 'account' || value === 'analytics' || value === 'brain' ? value : null;
67
+ }
68
+ function connectedSurfaceForHostedUrl(targetUrl, hubUrl) {
69
+ try {
70
+ const parsed = new URL(targetUrl);
71
+ const hub = new URL(hubUrl);
72
+ const remote = new URL((0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
73
+ if (parsed.origin === hub.origin || parsed.origin !== remote.origin)
74
+ return null;
75
+ const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
76
+ if (pathname === '/account')
77
+ return 'account';
78
+ if (pathname === '/analytics')
79
+ return 'analytics';
80
+ if (pathname === '/fraim-brain' || pathname === '/fraim-brain.html')
81
+ return 'brain';
82
+ if (pathname === '/ai-hub') {
83
+ return connectedSurfaceParam(parsed.searchParams.get('connected') || parsed.searchParams.get('open'));
84
+ }
85
+ if (pathname === '/auth/sign-in.html' || pathname === '/auth/recovery.html' || pathname === '/auth/error.html') {
86
+ return connectedSurfaceParam(parsed.searchParams.get('surface'));
87
+ }
88
+ return null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function localHubConnectedUrl(hubUrl, surface, sourceUrl) {
95
+ const target = new URL(hubUrl);
96
+ target.search = '';
97
+ target.hash = '';
98
+ target.searchParams.set('connected', surface);
99
+ if (sourceUrl) {
100
+ try {
101
+ const source = new URL(sourceUrl);
102
+ const apiKey = source.searchParams.get('api-key') || source.searchParams.get('apiKey');
103
+ if (apiKey)
104
+ target.searchParams.set('api-key', apiKey);
105
+ }
106
+ catch { /* ignore malformed source URLs */ }
107
+ }
108
+ return target.toString();
109
+ }
110
+ function wrappedHostedNavigationUrl(targetUrl, hubUrl) {
111
+ const surface = connectedSurfaceForHostedUrl(targetUrl, hubUrl);
112
+ return surface ? localHubConnectedUrl(hubUrl, surface, targetUrl) : null;
113
+ }
65
114
  // ---------------------------------------------------------------------------
66
115
  // Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
67
116
  // so the app never crashes if assets aren't present.
@@ -216,11 +265,42 @@ async function createWindow(url) {
216
265
  mainWindow.on('closed', () => electron_1.nativeTheme.removeListener('updated', applyOverlay));
217
266
  }
218
267
  mainWindow.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
268
+ const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
269
+ if (wrappedUrl) {
270
+ void mainWindow?.loadURL(wrappedUrl);
271
+ return { action: 'deny' };
272
+ }
219
273
  if (isTrustedInAppNavigation(targetUrl, url)) {
220
274
  void mainWindow?.loadURL(targetUrl);
221
275
  }
222
276
  return { action: 'deny' };
223
277
  });
278
+ mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
279
+ const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
280
+ if (!wrappedUrl)
281
+ return;
282
+ event.preventDefault();
283
+ if (mainWindow?.webContents.getURL() !== wrappedUrl) {
284
+ void mainWindow?.loadURL(wrappedUrl);
285
+ }
286
+ });
287
+ mainWindow.webContents.on('will-redirect', (details) => {
288
+ if (!details.isMainFrame)
289
+ return;
290
+ const wrappedUrl = wrappedHostedNavigationUrl(details.url, url);
291
+ if (!wrappedUrl)
292
+ return;
293
+ details.preventDefault();
294
+ if (mainWindow?.webContents.getURL() !== wrappedUrl) {
295
+ void mainWindow?.loadURL(wrappedUrl);
296
+ }
297
+ });
298
+ mainWindow.webContents.on('did-navigate', (_event, targetUrl) => {
299
+ const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
300
+ if (!wrappedUrl || mainWindow?.webContents.getURL() === wrappedUrl)
301
+ return;
302
+ void mainWindow?.loadURL(wrappedUrl);
303
+ });
224
304
  mainWindow.once('ready-to-show', () => mainWindow?.show());
225
305
  // Closing the window hides it to the tray rather than quitting the app.
226
306
  // The server keeps running so Word can still reach the task pane.
@@ -128,10 +128,19 @@ function buildHubPersonaHireUrl(personaKey, hireMode = 'job') {
128
128
  const params = new URLSearchParams({ persona: personaKey, mode: hireMode });
129
129
  return `/pricing?${params.toString()}`;
130
130
  }
131
- function buildHostedAuthUrl(surface, redirectTo) {
131
+ function buildLocalHubReturnUrl(req, surface) {
132
+ const proto = req.protocol || 'http';
133
+ const host = req.get('host') || '127.0.0.1';
134
+ const target = new URL('/ai-hub/', `${proto}://${host}`);
135
+ target.searchParams.set('connected', surface);
136
+ return target.toString();
137
+ }
138
+ function buildHostedAuthUrl(surface, redirectTo, hubReturn) {
132
139
  const target = new URL('/auth/sign-in.html', (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
133
140
  target.searchParams.set('surface', surface);
134
141
  target.searchParams.set('redirect_to', redirectTo);
142
+ if (hubReturn)
143
+ target.searchParams.set('hub_return', hubReturn);
135
144
  return target.toString();
136
145
  }
137
146
  function buildHostedPathUrl(pathname, queryString) {
@@ -2012,13 +2021,20 @@ class AiHubServer {
2012
2021
  if (!this.dbService) {
2013
2022
  this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
2014
2023
  const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
2024
+ if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
2025
+ const rawSurface = parsed.searchParams.get('surface');
2026
+ const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
2027
+ ? rawSurface
2028
+ : 'analytics';
2029
+ parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
2030
+ }
2015
2031
  res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
2016
2032
  });
2017
- this.app.get(['/account', '/account/'], (_req, res) => {
2018
- res.redirect(buildHostedAuthUrl('account', '/account/'));
2033
+ this.app.get(['/account', '/account/'], (req, res) => {
2034
+ res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
2019
2035
  });
2020
- this.app.get(['/analytics', '/analytics/'], (_req, res) => {
2021
- res.redirect(buildHostedAuthUrl('analytics', '/analytics/'));
2036
+ this.app.get(['/analytics', '/analytics/'], (req, res) => {
2037
+ res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
2022
2038
  });
2023
2039
  }
2024
2040
  else {
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerAppRoutes = registerAppRoutes;
7
+ exports.registerProtectedAppRoutes = registerProtectedAppRoutes;
7
8
  const path_1 = __importDefault(require("path"));
8
9
  /**
9
10
  * Serve the unified tabs experience at the root path.
@@ -30,3 +31,95 @@ function registerAppRoutes(app, deps) {
30
31
  }
31
32
  });
32
33
  }
34
+ function requestBaseUrl(req) {
35
+ const fromEnv = process.env.FRAIM_PUBLIC_BASE_URL;
36
+ if (fromEnv && /^https?:\/\//i.test(fromEnv))
37
+ return fromEnv.replace(/\/+$/, '');
38
+ const firstHeader = (value) => Array.isArray(value) ? value[0] : value;
39
+ const proto = firstHeader(req.headers['x-forwarded-proto']) || req.protocol || 'http';
40
+ const host = firstHeader(req.headers['x-forwarded-host']) || req.get('host') || 'localhost';
41
+ return `${proto.split(',')[0]}://${host.split(',')[0]}`.replace(/\/+$/, '');
42
+ }
43
+ function hostedProjectSummary() {
44
+ const projectPath = process.cwd();
45
+ return {
46
+ path: projectPath,
47
+ exists: true,
48
+ hasFraim: true,
49
+ needsOnboarding: false,
50
+ message: '',
51
+ };
52
+ }
53
+ function hostedTeamContext() {
54
+ const emptyFile = { present: false, displayPath: '' };
55
+ return {
56
+ orgContext: emptyFile,
57
+ managerContext: emptyFile,
58
+ orgRules: emptyFile,
59
+ managerRules: emptyFile,
60
+ projectContext: emptyFile,
61
+ projectBrief: emptyFile,
62
+ projectQa: emptyFile,
63
+ projectRules: emptyFile,
64
+ };
65
+ }
66
+ /**
67
+ * Protected app routes for hosted web surfaces.
68
+ *
69
+ * The deployed server owns authentication/session identity and can provide a
70
+ * narrow bootstrap contract for the hosted Hub shell. It intentionally does not
71
+ * instantiate the local AiHubServer runtime, which belongs to the companion app.
72
+ */
73
+ function registerProtectedAppRoutes(app, deps) {
74
+ void deps;
75
+ app.get('/api/ai-hub/bootstrap', (req, res) => {
76
+ const apiKeyData = req.apiKeyData;
77
+ if (!apiKeyData?.userId) {
78
+ return res.status(401).json({ error: 'Authentication required' });
79
+ }
80
+ const project = hostedProjectSummary();
81
+ const projectName = path_1.default.basename(project.path) || 'FRAIM';
82
+ const projectEntry = {
83
+ id: 'hosted-fraim',
84
+ name: projectName,
85
+ folderPath: project.path,
86
+ intent: '',
87
+ outcome: '',
88
+ rules: '',
89
+ brief: '',
90
+ };
91
+ return res.json({
92
+ title: 'AI Hub',
93
+ remoteBaseUrl: requestBaseUrl(req),
94
+ project,
95
+ preferences: {
96
+ projectPath: project.path,
97
+ employeeId: 'claude',
98
+ categoryId: 'hosted',
99
+ recentJobIds: [],
100
+ recentJobInstructions: {},
101
+ personaKey: null,
102
+ projects: [projectEntry],
103
+ },
104
+ categories: [],
105
+ jobs: [],
106
+ managerTemplates: [],
107
+ employees: [
108
+ { id: 'claude', label: 'Claude', available: true, detail: 'Hosted' },
109
+ ],
110
+ personas: [],
111
+ subscriptionActive: false,
112
+ projects: [projectEntry],
113
+ firstRun: { install: true, company: true, hire: true, project: true },
114
+ teamContext: hostedTeamContext(),
115
+ brain: {
116
+ learnings: { organization: 0, manager: 0, project: 0, rawSignals: 0 },
117
+ catalog: { jobs: 0, skills: 0, rules: 0 },
118
+ },
119
+ userEmail: apiKeyData.userId,
120
+ assignments: { byProject: {}, source: 'client-localStorage' },
121
+ managerHiring: { qualities: [], services: [], roles: {} },
122
+ managerTeam: [],
123
+ });
124
+ });
125
+ }
@@ -238,7 +238,11 @@ function registerPublicAuthRoutes(app, deps) {
238
238
  if (surface === 'mobile') {
239
239
  return res.status(200).json({ ok: true, mobileCode: issueMobileExchangeCode(apiKeyData.key) });
240
240
  }
241
- return res.status(200).json({ ok: true, redirectTo: requestedRedirect });
241
+ // The account UI can reveal the user's API key immediately after a
242
+ // valid session is established. Return it here as well so embedded
243
+ // Hub auth can hand the key to its parent without depending on
244
+ // third-party iframe cookies.
245
+ return res.status(200).json({ ok: true, redirectTo: requestedRedirect, apiKey: apiKeyData.key });
242
246
  }
243
247
  catch (err) {
244
248
  console.error('[FRAIM AUTH] /api/auth/email/verify-code error:', err instanceof Error ? err.message : String(err));
@@ -61,6 +61,38 @@ function buildBaseUrl(req) {
61
61
  function callbackUrl(req, provider) {
62
62
  return `${buildBaseUrl(req)}/api/auth/oauth/${provider}/callback`;
63
63
  }
64
+ function isLoopbackHost(hostname) {
65
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
66
+ }
67
+ function safeLoopbackHubReturn(input) {
68
+ if (typeof input !== 'string' || !input)
69
+ return undefined;
70
+ if (input.includes('\n') || input.includes('\r'))
71
+ return undefined;
72
+ try {
73
+ const url = new URL(input);
74
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !isLoopbackHost(url.hostname))
75
+ return undefined;
76
+ if (url.username || url.password)
77
+ return undefined;
78
+ if (!url.pathname.startsWith('/ai-hub/'))
79
+ return undefined;
80
+ return url.toString();
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ function buildHubReturnRedirect(hubReturn, surface, apiKey) {
87
+ if (!hubReturn || !apiKey)
88
+ return null;
89
+ const target = new URL(hubReturn);
90
+ if (!target.searchParams.get('connected') && surface !== 'mobile') {
91
+ target.searchParams.set('connected', surface);
92
+ }
93
+ target.searchParams.set('api-key', apiKey);
94
+ return target.toString();
95
+ }
64
96
  function decodeJwtPayload(jwt) {
65
97
  const parts = jwt.split('.');
66
98
  if (parts.length !== 3)
@@ -86,9 +118,10 @@ function registerOAuthRoutes(app, deps) {
86
118
  });
87
119
  // Helper used by both providers to finalise authentication once we have a verified email.
88
120
  async function completeSignIn(req, res, params) {
89
- const { verifiedEmail, provider, surface, redirectTo, intent } = params;
121
+ const { verifiedEmail, provider, surface, redirectTo, intent, hubReturn } = params;
90
122
  const lower = verifiedEmail.toLowerCase();
91
123
  const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
124
+ let apiKeyForHubReturn = apiKeyData?.key ?? '';
92
125
  if (!apiKeyData && intent !== 'signup') {
93
126
  // Sign-in is NOT a sign-up. If the verified email doesn't already match a
94
127
  // FRAIM account, route the user to the sign-up flow. Account creation is
@@ -107,7 +140,7 @@ function registerOAuthRoutes(app, deps) {
107
140
  return res.redirect(`/auth/error.html?reason=no_account`);
108
141
  }
109
142
  if (!apiKeyData && intent === 'signup') {
110
- await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
143
+ apiKeyForHubReturn = await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
111
144
  }
112
145
  const sessionId = (0, oauth_helpers_1.generateSessionId)();
113
146
  await dbService.createAuthSession({
@@ -121,6 +154,10 @@ function registerOAuthRoutes(app, deps) {
121
154
  await (0, audit_log_1.auditLog)('SESSION_CREATED', { userId: lower, sessionId, surface, provider, outcome: 'success' });
122
155
  (0, cookie_service_1.setSessionCookie)(res, sessionId);
123
156
  (0, cookie_service_1.clearOAuthPendingCookie)(res);
157
+ const hubRedirect = buildHubReturnRedirect(hubReturn, surface, apiKeyForHubReturn);
158
+ if (hubRedirect) {
159
+ return res.redirect(hubRedirect);
160
+ }
124
161
  res.redirect(redirectTo);
125
162
  }
126
163
  app.get('/api/auth/oauth/:provider/start', (req, res, next) => {
@@ -136,6 +173,7 @@ function registerOAuthRoutes(app, deps) {
136
173
  const surface = (0, oauth_helpers_1.pickSurfaceOrDefault)(req.query.surface);
137
174
  const intent = pickIntent(req.query.intent);
138
175
  const redirectTo = (0, oauth_helpers_1.safeRedirectPath)(req.query.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
176
+ const hubReturn = safeLoopbackHubReturn(req.query.hub_return);
139
177
  try {
140
178
  const codeVerifier = (0, oauth_helpers_1.generatePkceVerifier)();
141
179
  const stateNonce = (0, oauth_helpers_1.generateStateNonce)();
@@ -148,6 +186,7 @@ function registerOAuthRoutes(app, deps) {
148
186
  codeVerifier,
149
187
  stateNonce,
150
188
  redirectTo,
189
+ hubReturn,
151
190
  });
152
191
  (0, cookie_service_1.setOAuthPendingCookie)(res, pendingId);
153
192
  await (0, audit_log_1.auditLog)('OAUTH_START', { provider, surface, ip: (0, oauth_helpers_1.clientIpFromReq)(req), outcome: 'success' });
@@ -257,6 +296,7 @@ function registerOAuthRoutes(app, deps) {
257
296
  surface: pending.surface,
258
297
  redirectTo: pending.redirectTo,
259
298
  intent: pickIntent(pending.intent),
299
+ hubReturn: pending.hubReturn,
260
300
  });
261
301
  }
262
302
  catch (err) {
@@ -350,6 +390,7 @@ function registerOAuthRoutes(app, deps) {
350
390
  surface: pending.surface,
351
391
  redirectTo: pending.redirectTo,
352
392
  intent: pickIntent(pending.intent),
393
+ hubReturn: pending.hubReturn,
353
394
  });
354
395
  }
355
396
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.189",
3
+ "version": "2.0.191",
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": {
@@ -45,11 +45,13 @@ const state = {
45
45
  selectedJob: null, // chosen in modal step 1
46
46
  selectedEmployeeId: null,
47
47
  selectedPersonaKey: null, // R4: null = "All employees"
48
- modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
49
- storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
50
- remoteBaseUrl: null,
51
- connectedReturnArea: 'projects',
52
- panelState: {}, // { [convId]: { coach?: boolean } }
48
+ modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
49
+ storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
50
+ remoteBaseUrl: null,
51
+ connectedReturnArea: 'projects',
52
+ connectedSurface: null,
53
+ pendingConnectedSurfaceAfterAuth: null,
54
+ panelState: {}, // { [convId]: { coach?: boolean } }
53
55
  wordContext: null, // WordContext pushed from taskpane.html via postMessage
54
56
  // Pending coaching job selected via a quick-coach button or template picker.
55
57
  // Sent as coachingJobId in the next continueRun call and then cleared.
@@ -146,6 +148,34 @@ async function requestJson(url, options) {
146
148
  return payload;
147
149
  }
148
150
 
151
+ function tfIsLoopbackHost(hostname) {
152
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
153
+ }
154
+
155
+ function tfIsLoopbackOrigin(origin) {
156
+ try {
157
+ const parsed = new URL(origin);
158
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && tfIsLoopbackHost(parsed.hostname);
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ function tfConsumeReturnedApiKeyFromUrl() {
165
+ try {
166
+ if (!tfIsLoopbackHost(window.location.hostname)) return null;
167
+ const url = new URL(window.location.href);
168
+ const apiKey = url.searchParams.get('api-key') || url.searchParams.get('apiKey');
169
+ if (!apiKey) return null;
170
+ url.searchParams.delete('api-key');
171
+ url.searchParams.delete('apiKey');
172
+ window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
173
+ return apiKey;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
149
179
  function bootstrapCacheKey(projectPath, docUrl) {
150
180
  return `${tfCanonicalProjectPath(projectPath || '')}::${docUrl || ''}`;
151
181
  }
@@ -155,14 +185,14 @@ function cacheBootstrapPayload(bootstrap, docUrl) {
155
185
  bootstrapCache.set(bootstrapCacheKey(bootstrap.project.path, docUrl), bootstrap);
156
186
  }
157
187
 
158
- function applyBootstrap(bootstrap, docUrl) {
159
- state.bootstrap = bootstrap;
160
- state.projectPath = bootstrap.project.path;
161
- state.remoteBaseUrl = bootstrap.remoteBaseUrl || null;
162
- if (bootstrap.preferences && bootstrap.preferences.apiKey) {
163
- state.storedApiKey = bootstrap.preferences.apiKey;
164
- }
165
- state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
188
+ function applyBootstrap(bootstrap, docUrl) {
189
+ state.bootstrap = bootstrap;
190
+ state.projectPath = bootstrap.project.path;
191
+ state.remoteBaseUrl = bootstrap.remoteBaseUrl || null;
192
+ if (bootstrap.preferences && bootstrap.preferences.apiKey) {
193
+ state.storedApiKey = bootstrap.preferences.apiKey;
194
+ }
195
+ state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
166
196
  // Restore persona selection persisted on the server side.
167
197
  if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
168
198
  state.selectedPersonaKey = bootstrap.preferences.personaKey;
@@ -235,6 +265,20 @@ async function loadBootstrap(projectPath, docUrl, options) {
235
265
  return bootstrap;
236
266
  }
237
267
 
268
+ function hostedHubNeedsSignIn(error) {
269
+ if (!error || error.status !== 401) return false;
270
+ const host = window.location.hostname;
271
+ return host !== 'localhost' && host !== '127.0.0.1';
272
+ }
273
+
274
+ function redirectHostedHubToSignIn() {
275
+ const redirectTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/ai-hub/';
276
+ const target = new URL('/auth/sign-in.html', window.location.origin);
277
+ target.searchParams.set('surface', 'account');
278
+ target.searchParams.set('redirect_to', redirectTo);
279
+ window.location.href = target.toString();
280
+ }
281
+
238
282
  // ---------------------------------------------------------------------------
239
283
  // Word context bridge (postMessage from taskpane.html parent frame)
240
284
  // ---------------------------------------------------------------------------
@@ -5917,6 +5961,11 @@ function wireEvents() {
5917
5961
  const isFirstRun = urlParams.get('firstRun') === 'true';
5918
5962
  const surface = urlParams.get('surface') || 'hub';
5919
5963
  const docUrl = urlParams.get('docUrl') || '';
5964
+ const requestedConnectedSurface = tfRequestedConnectedSurface();
5965
+ const returnedApiKey = tfConsumeReturnedApiKeyFromUrl();
5966
+ if (returnedApiKey && requestedConnectedSurface) {
5967
+ state.pendingConnectedSurfaceAfterAuth = requestedConnectedSurface;
5968
+ }
5920
5969
 
5921
5970
  // Apply surface class before bootstrap so CSS takes effect immediately.
5922
5971
  if (surface !== 'hub') document.body.dataset.surface = surface;
@@ -5926,9 +5975,17 @@ function wireEvents() {
5926
5975
 
5927
5976
  try {
5928
5977
  await loadBootstrap(null, docUrl);
5978
+ if (returnedApiKey) {
5979
+ await tfRememberConnectedApiKey(returnedApiKey);
5980
+ tfOpenRequestedConnectedSurface();
5981
+ }
5929
5982
  await hydrateConversationsFromServer();
5930
5983
  startBgConvPoll();
5931
5984
  } catch (error) {
5985
+ if (hostedHubNeedsSignIn(error)) {
5986
+ redirectHostedHubToSignIn();
5987
+ return;
5988
+ }
5932
5989
  showStatus(error.message, true);
5933
5990
  return;
5934
5991
  }
@@ -6211,7 +6268,7 @@ const STORAGE_KEY_ASSIGNMENTS_512 = 'fraim.aiHub.assignments.v1';
6211
6268
 
6212
6269
  const tf = {
6213
6270
  enabled: false,
6214
- area: 'projects', // company | manager | projects | brain | connected
6271
+ area: 'projects', // company | manager | projects | brain | connected
6215
6272
  projectView: 'overview', // overview | workspace
6216
6273
  activeProjectId: null,
6217
6274
  projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
@@ -9242,107 +9299,158 @@ function tfToggleAccountMenu(e) {
9242
9299
  const menu = document.getElementById('account-menu');
9243
9300
  if (menu) menu.classList.toggle('open');
9244
9301
  }
9245
- function tfCloseAccountMenu() {
9246
- const menu = document.getElementById('account-menu');
9247
- if (menu) menu.classList.remove('open');
9248
- }
9249
-
9250
- const CONNECTED_SURFACES = {
9251
- account: { title: 'Account', path: '/account/' },
9252
- analytics: { title: 'Analytics', path: '/analytics/' },
9253
- brain: { title: 'Brain', path: '/fraim-brain.html', redirectPath: '/fraim-brain' },
9254
- };
9255
-
9256
- function tfConnectedRemoteBase() {
9257
- const fallback = window.location.origin;
9258
- const raw = state.remoteBaseUrl || (state.bootstrap && state.bootstrap.remoteBaseUrl) || fallback;
9259
- try {
9260
- return new URL(raw, fallback).toString();
9261
- } catch {
9262
- return fallback;
9263
- }
9264
- }
9265
-
9266
- function tfConnectedApiKey() {
9267
- return state.storedApiKey || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.apiKey) || '';
9268
- }
9269
-
9270
- function tfConnectedSurfaceUrl(surface) {
9271
- const cfg = CONNECTED_SURFACES[surface];
9272
- const remoteBase = tfConnectedRemoteBase();
9273
- if (!cfg) return remoteBase;
9274
- const apiKey = tfConnectedApiKey();
9275
- if (apiKey) {
9276
- const target = new URL(cfg.path, remoteBase);
9277
- target.searchParams.set('api-key', apiKey);
9278
- return target.toString();
9279
- }
9280
- const signIn = new URL('/auth/sign-in.html', remoteBase);
9281
- signIn.searchParams.set('surface', surface);
9282
- signIn.searchParams.set('redirect_to', cfg.redirectPath || cfg.path);
9283
- return signIn.toString();
9284
- }
9285
-
9286
- function tfOpenConnectedSurface(surface) {
9287
- const cfg = CONNECTED_SURFACES[surface];
9288
- if (!cfg) return;
9289
- tfCloseAccountMenu();
9290
- state.connectedReturnArea = (tf.area && tf.area !== 'connected') ? tf.area : 'projects';
9291
- const url = tfConnectedSurfaceUrl(surface);
9292
- const title = document.getElementById('connected-title');
9293
- const origin = document.getElementById('connected-origin');
9294
- const frame = document.getElementById('connected-frame');
9295
- if (title) title.textContent = cfg.title;
9296
- if (origin) {
9297
- try { origin.textContent = new URL(url).origin; }
9298
- catch { origin.textContent = ''; }
9299
- }
9300
- if (frame) {
9301
- frame.title = `FRAIM ${cfg.title}`;
9302
- frame.src = url;
9303
- }
9304
- tfShowArea('connected');
9305
- }
9306
-
9307
- function tfCloseConnectedSurface() {
9308
- const frame = document.getElementById('connected-frame');
9309
- if (frame) frame.removeAttribute('src');
9310
- const returnArea = state.connectedReturnArea && state.connectedReturnArea !== 'connected'
9311
- ? state.connectedReturnArea
9312
- : 'projects';
9313
- tfShowArea(returnArea);
9314
- }
9315
-
9302
+ function tfCloseAccountMenu() {
9303
+ const menu = document.getElementById('account-menu');
9304
+ if (menu) menu.classList.remove('open');
9305
+ }
9306
+
9307
+ const CONNECTED_SURFACES = {
9308
+ account: { title: 'Account', path: '/account/' },
9309
+ analytics: { title: 'Analytics', path: '/analytics/' },
9310
+ brain: { title: 'Brain', path: '/fraim-brain.html', redirectPath: '/fraim-brain' },
9311
+ };
9312
+
9313
+ function tfConnectedRemoteBase() {
9314
+ const fallback = window.location.origin;
9315
+ const raw = state.remoteBaseUrl || (state.bootstrap && state.bootstrap.remoteBaseUrl) || fallback;
9316
+ try {
9317
+ return new URL(raw, fallback).toString();
9318
+ } catch {
9319
+ return fallback;
9320
+ }
9321
+ }
9322
+
9323
+ function tfConnectedApiKey() {
9324
+ return state.storedApiKey || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.apiKey) || '';
9325
+ }
9326
+
9327
+ function tfConnectedRedirectPath(surface, cfg, remoteBase) {
9328
+ try {
9329
+ if (new URL(remoteBase).origin === window.location.origin) {
9330
+ return `/ai-hub/?connected=${encodeURIComponent(surface)}`;
9331
+ }
9332
+ } catch {}
9333
+ return cfg.redirectPath || cfg.path;
9334
+ }
9335
+
9336
+ function tfConnectedHubReturnUrl(surface) {
9337
+ if (!tfIsLoopbackOrigin(window.location.origin)) return '';
9338
+ try {
9339
+ const target = new URL('/ai-hub/', window.location.origin);
9340
+ target.searchParams.set('connected', surface);
9341
+ return target.toString();
9342
+ } catch {
9343
+ return '';
9344
+ }
9345
+ }
9346
+
9347
+ function tfConnectedSurfaceUrl(surface) {
9348
+ const cfg = CONNECTED_SURFACES[surface];
9349
+ const remoteBase = tfConnectedRemoteBase();
9350
+ if (!cfg) return remoteBase;
9351
+ const apiKey = tfConnectedApiKey();
9352
+ try {
9353
+ if (new URL(remoteBase).origin === window.location.origin) {
9354
+ return new URL(cfg.path, remoteBase).toString();
9355
+ }
9356
+ } catch {}
9357
+ if (apiKey) {
9358
+ const target = new URL(cfg.path, remoteBase);
9359
+ target.searchParams.set('api-key', apiKey);
9360
+ return target.toString();
9361
+ }
9362
+ const signIn = new URL('/auth/sign-in.html', remoteBase);
9363
+ signIn.searchParams.set('surface', surface);
9364
+ signIn.searchParams.set('embedded', '1');
9365
+ signIn.searchParams.set('redirect_to', tfConnectedRedirectPath(surface, cfg, remoteBase));
9366
+ const hubReturn = tfConnectedHubReturnUrl(surface);
9367
+ if (hubReturn) signIn.searchParams.set('hub_return', hubReturn);
9368
+ return signIn.toString();
9369
+ }
9370
+
9371
+ function tfOpenConnectedSurface(surface) {
9372
+ const cfg = CONNECTED_SURFACES[surface];
9373
+ if (!cfg) return;
9374
+ tfCloseAccountMenu();
9375
+ state.connectedReturnArea = (tf.area && tf.area !== 'connected') ? tf.area : 'projects';
9376
+ state.connectedSurface = surface;
9377
+ const url = tfConnectedSurfaceUrl(surface);
9378
+ const title = document.getElementById('connected-title');
9379
+ const origin = document.getElementById('connected-origin');
9380
+ const frame = document.getElementById('connected-frame');
9381
+ if (title) title.textContent = cfg.title;
9382
+ if (origin) {
9383
+ try { origin.textContent = new URL(url).origin; }
9384
+ catch { origin.textContent = ''; }
9385
+ }
9386
+ if (frame) {
9387
+ frame.title = `FRAIM ${cfg.title}`;
9388
+ frame.src = url;
9389
+ }
9390
+ tfShowArea('connected');
9391
+ }
9392
+
9393
+ function tfCloseConnectedSurface() {
9394
+ const frame = document.getElementById('connected-frame');
9395
+ if (frame) frame.removeAttribute('src');
9396
+ state.connectedSurface = null;
9397
+ const returnArea = state.connectedReturnArea && state.connectedReturnArea !== 'connected'
9398
+ ? state.connectedReturnArea
9399
+ : 'projects';
9400
+ tfShowArea(returnArea);
9401
+ }
9402
+
9316
9403
  async function tfHandleConnectedAuthMessage(event) {
9317
9404
  const frame = document.getElementById('connected-frame');
9318
9405
  if (frame && frame.contentWindow && event.source !== frame.contentWindow) return;
9319
9406
  let remoteOrigin = '';
9320
9407
  try { remoteOrigin = new URL(tfConnectedRemoteBase()).origin; }
9321
- catch { return; }
9322
- if (event.origin !== remoteOrigin) return;
9408
+ catch { return; }
9409
+ if (event.origin !== remoteOrigin) return;
9323
9410
  const msg = event.data || {};
9324
9411
  if (!msg || msg.type !== 'fraim-auth-api-key' || typeof msg.apiKey !== 'string' || !msg.apiKey) return;
9325
- if (msg.apiKey === state.storedApiKey) return;
9326
- state.storedApiKey = msg.apiKey;
9327
- if (state.bootstrap && state.bootstrap.preferences) {
9328
- state.bootstrap.preferences.apiKey = msg.apiKey;
9329
- }
9330
- try {
9331
- await requestJson('/api/ai-hub/api-key', {
9332
- method: 'POST',
9333
- headers: { 'Content-Type': 'application/json' },
9334
- body: JSON.stringify({ apiKey: msg.apiKey }),
9335
- });
9336
- } catch (error) {
9337
- console.warn('[ai-hub] Could not persist connected auth key:', error);
9412
+ const previousKey = tfConnectedApiKey();
9413
+ await tfRememberConnectedApiKey(msg.apiKey);
9414
+ if (state.connectedSurface) {
9415
+ const nextUrl = tfConnectedSurfaceUrl(state.connectedSurface);
9416
+ const frameEl = document.getElementById('connected-frame');
9417
+ if (frameEl) {
9418
+ try {
9419
+ const current = new URL(frameEl.src || '', window.location.href);
9420
+ const next = new URL(nextUrl, window.location.href);
9421
+ if (previousKey === msg.apiKey && current.origin === next.origin && current.pathname === next.pathname) {
9422
+ return;
9423
+ }
9424
+ } catch {}
9425
+ frameEl.src = nextUrl;
9426
+ }
9338
9427
  }
9339
9428
  }
9340
-
9341
- // ---------------------------------------------------------------------------
9342
- // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
9429
+
9430
+ async function tfRememberConnectedApiKey(apiKey) {
9431
+ const changed = apiKey !== state.storedApiKey;
9432
+ state.storedApiKey = apiKey;
9433
+ if (state.bootstrap && state.bootstrap.preferences) {
9434
+ state.bootstrap.preferences.apiKey = apiKey;
9435
+ }
9436
+ if (changed) {
9437
+ try {
9438
+ await requestJson('/api/ai-hub/api-key', {
9439
+ method: 'POST',
9440
+ headers: { 'Content-Type': 'application/json' },
9441
+ body: JSON.stringify({ apiKey }),
9442
+ });
9443
+ } catch (error) {
9444
+ console.warn('[ai-hub] Could not persist connected auth key:', error);
9445
+ }
9446
+ }
9447
+ }
9448
+
9449
+ // ---------------------------------------------------------------------------
9450
+ // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
9343
9451
  // sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
9344
9452
  // OS preference; here we reflect it in the toggle control and persist changes.
9345
- // Embedded connected surfaces receive live theme updates via postMessage.
9453
+ // Embedded connected surfaces receive live theme updates via postMessage.
9346
9454
  // ---------------------------------------------------------------------------
9347
9455
  function tfCurrentTheme() {
9348
9456
  return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
@@ -9362,18 +9470,18 @@ function tfApplyTheme(theme, persist) {
9362
9470
  document.documentElement.setAttribute('data-theme', t);
9363
9471
  if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
9364
9472
  tfSyncThemeToggle(t);
9365
- // Keep embedded surfaces in sync live, including the connected main-server frame.
9366
- try {
9367
- document.querySelectorAll('iframe').forEach((f) => {
9368
- if (!f.contentWindow) return;
9369
- let targetOrigin = location.origin;
9370
- try {
9371
- if (f.src) targetOrigin = new URL(f.src, location.href).origin;
9372
- } catch (e) {}
9373
- f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, targetOrigin);
9374
- });
9375
- } catch (e) {}
9376
- }
9473
+ // Keep embedded surfaces in sync live, including the connected main-server frame.
9474
+ try {
9475
+ document.querySelectorAll('iframe').forEach((f) => {
9476
+ if (!f.contentWindow) return;
9477
+ let targetOrigin = location.origin;
9478
+ try {
9479
+ if (f.src) targetOrigin = new URL(f.src, location.href).origin;
9480
+ } catch (e) {}
9481
+ f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, targetOrigin);
9482
+ });
9483
+ } catch (e) {}
9484
+ }
9377
9485
  function tfToggleTheme(e) {
9378
9486
  if (e) e.stopPropagation();
9379
9487
  tfApplyTheme(tfCurrentTheme() === 'dark' ? 'light' : 'dark', true);
@@ -10099,31 +10207,31 @@ function tfWireShell() {
10099
10207
  for (const tab of document.querySelectorAll('.hub-tab')) {
10100
10208
  tab.addEventListener('click', () => tfShowArea(tab.dataset.area));
10101
10209
  }
10102
- const avatar = document.getElementById('avatar-btn');
10103
- if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
10104
- tfWireThemeToggle();
10105
- const accountItem = document.getElementById('am-account');
10106
- if (accountItem) accountItem.addEventListener('click', (e) => {
10107
- e.preventDefault();
10108
- tfOpenConnectedSurface('account');
10109
- });
10110
- const brainItem = document.getElementById('am-brain');
10111
- if (brainItem) brainItem.addEventListener('click', () => {
10112
- tfOpenConnectedSurface('brain');
10113
- });
10114
- const analyticsItem = document.getElementById('am-analytics');
10115
- if (analyticsItem) analyticsItem.addEventListener('click', (e) => {
10116
- e.preventDefault();
10117
- tfOpenConnectedSurface('analytics');
10118
- });
10119
- const connectedClose = document.getElementById('connected-close');
10120
- if (connectedClose) connectedClose.addEventListener('click', tfCloseConnectedSurface);
10121
- if (!tf.connectedAuthMessageWired) {
10122
- window.addEventListener('message', tfHandleConnectedAuthMessage);
10123
- tf.connectedAuthMessageWired = true;
10124
- }
10125
- const signout = document.getElementById('am-signout');
10126
- if (signout) signout.addEventListener('click', () => { window.location.href = '/auth/sign-in.html'; });
10210
+ const avatar = document.getElementById('avatar-btn');
10211
+ if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
10212
+ tfWireThemeToggle();
10213
+ const accountItem = document.getElementById('am-account');
10214
+ if (accountItem) accountItem.addEventListener('click', (e) => {
10215
+ e.preventDefault();
10216
+ tfOpenConnectedSurface('account');
10217
+ });
10218
+ const brainItem = document.getElementById('am-brain');
10219
+ if (brainItem) brainItem.addEventListener('click', () => {
10220
+ tfOpenConnectedSurface('brain');
10221
+ });
10222
+ const analyticsItem = document.getElementById('am-analytics');
10223
+ if (analyticsItem) analyticsItem.addEventListener('click', (e) => {
10224
+ e.preventDefault();
10225
+ tfOpenConnectedSurface('analytics');
10226
+ });
10227
+ const connectedClose = document.getElementById('connected-close');
10228
+ if (connectedClose) connectedClose.addEventListener('click', tfCloseConnectedSurface);
10229
+ if (!tf.connectedAuthMessageWired) {
10230
+ window.addEventListener('message', tfHandleConnectedAuthMessage);
10231
+ tf.connectedAuthMessageWired = true;
10232
+ }
10233
+ const signout = document.getElementById('am-signout');
10234
+ if (signout) signout.addEventListener('click', () => { window.location.href = '/auth/sign-in.html'; });
10127
10235
  document.addEventListener('click', (e) => {
10128
10236
  const menu = document.getElementById('account-menu');
10129
10237
  if (menu && menu.classList.contains('open') && !(e.target.closest && e.target.closest('.nav-right'))) {
@@ -10231,15 +10339,15 @@ function tfWireShell() {
10231
10339
  ['assign-job-modal', tfCloseAssignJob],
10232
10340
  ['np-modal', tfCloseNewProject],
10233
10341
  ];
10234
- document.addEventListener('keydown', (e) => {
10235
- if (e.key !== 'Escape') return;
10236
- if (tf.area === 'connected') {
10237
- e.stopPropagation();
10238
- tfCloseConnectedSurface();
10239
- return;
10240
- }
10241
- for (const [id, fn] of escClosers) {
10242
- const el = document.getElementById(id);
10342
+ document.addEventListener('keydown', (e) => {
10343
+ if (e.key !== 'Escape') return;
10344
+ if (tf.area === 'connected') {
10345
+ e.stopPropagation();
10346
+ tfCloseConnectedSurface();
10347
+ return;
10348
+ }
10349
+ for (const [id, fn] of escClosers) {
10350
+ const el = document.getElementById(id);
10243
10351
  if (el && !el.hidden) { e.stopPropagation(); fn(); return; }
10244
10352
  }
10245
10353
  });
@@ -10274,6 +10382,36 @@ function tfPopulateAccountMenu() {
10274
10382
  if (avatarBtn) avatarBtn.textContent = id.initials;
10275
10383
  }
10276
10384
 
10385
+ function tfRequestedConnectedSurface() {
10386
+ try {
10387
+ const params = new URLSearchParams(window.location.search);
10388
+ const surface = params.get('connected') || params.get('open');
10389
+ return CONNECTED_SURFACES[surface] ? surface : null;
10390
+ } catch {
10391
+ return null;
10392
+ }
10393
+ }
10394
+
10395
+ function tfClearConnectedSurfaceRequest() {
10396
+ try {
10397
+ const url = new URL(window.location.href);
10398
+ url.searchParams.delete('connected');
10399
+ url.searchParams.delete('open');
10400
+ window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
10401
+ } catch {}
10402
+ }
10403
+
10404
+ function tfOpenRequestedConnectedSurface() {
10405
+ if (!tf.enabled) return;
10406
+ const pendingAfterAuth = state.pendingConnectedSurfaceAfterAuth;
10407
+ if (pendingAfterAuth && !tfConnectedApiKey()) return;
10408
+ const surface = pendingAfterAuth || tfRequestedConnectedSurface();
10409
+ if (!surface) return;
10410
+ state.pendingConnectedSurfaceAfterAuth = null;
10411
+ tfClearConnectedSurfaceRequest();
10412
+ setTimeout(() => tfOpenConnectedSurface(surface), 0);
10413
+ }
10414
+
10277
10415
  function tfInitShell() {
10278
10416
  if (typeof surface !== 'undefined' && surface !== 'hub') return;
10279
10417
  if (document.body.dataset.surface && document.body.dataset.surface !== 'hub') return;
@@ -10312,6 +10450,7 @@ function tfInitShell() {
10312
10450
  // the project workspace. Re-render once the workspace is selected so manager
10313
10451
  // delegation boards are visible on first load, not only after a refresh tick.
10314
10452
  renderActive();
10453
+ tfOpenRequestedConnectedSurface();
10315
10454
  }
10316
10455
 
10317
10456
  // Refresh shell-derived UI after a bootstrap reload (e.g. project picked).