fraim-framework 2.0.198 → 2.0.200

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.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AiHubConversationStore = exports.COMPANY_SCOPE_KEY = exports.MANAGER_SCOPE_KEY = void 0;
7
7
  exports.conversationScopeKey = conversationScopeKey;
8
+ exports.migrateConversationStore = migrateConversationStore;
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const path_1 = __importDefault(require("path"));
10
11
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
@@ -93,6 +94,103 @@ function migrateScopeBuckets(store) {
93
94
  }
94
95
  return { version: 1, projects };
95
96
  }
97
+ // Canonical comparison key for a project path (case-insensitive on win32), matching the
98
+ // convention used by removeProject and the preferences store.
99
+ function canonicalProjectPathKey(value) {
100
+ const resolved = path_1.default.resolve(value);
101
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
102
+ }
103
+ // How much run history a record carries. Used to decide the winner when the same conversation id
104
+ // exists in two buckets: the copy with real history must never be overwritten by a header-only
105
+ // shell (which is exactly what a mis-filed slim PUT produces).
106
+ function conversationRichness(conv) {
107
+ const value = conv;
108
+ const messages = Array.isArray(value?.messages) ? value.messages.length : 0;
109
+ const events = Array.isArray(value?.events) ? value.events.length : 0;
110
+ return messages + events;
111
+ }
112
+ // Place a raw conversation into a bucket, deduping by id. When the id already exists, keep the
113
+ // richer copy (more run history); tie-break on newer lastUpdatedAt. Order-independent.
114
+ function placeConversationInBucket(bucket, conv) {
115
+ const value = conv;
116
+ const id = value && typeof value.id === 'string' ? value.id : '';
117
+ if (!id) {
118
+ bucket.conversations.push(conv);
119
+ return;
120
+ }
121
+ const idx = bucket.conversations.findIndex((entry) => entry && entry.id === id);
122
+ if (idx < 0) {
123
+ bucket.conversations.push(conv);
124
+ return;
125
+ }
126
+ const existing = bucket.conversations[idx];
127
+ const existingScore = conversationRichness(existing);
128
+ const incomingScore = conversationRichness(conv);
129
+ if (incomingScore > existingScore) {
130
+ bucket.conversations[idx] = conv;
131
+ }
132
+ else if (incomingScore === existingScore
133
+ && timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
134
+ bucket.conversations[idx] = conv;
135
+ }
136
+ }
137
+ // A conversation's identity is unique only WITHIN a bucket, and a bucket key is a project path.
138
+ // The buggy client PUT path could file a conversation whose own projectPath points at project B
139
+ // into project A's bucket (see docs/rca/hub-conversation-cross-project-leak.md), so the same run
140
+ // appeared under two projects and the mis-filed copy was a header-only shell. This one-time,
141
+ // idempotent, read-time migration relocates every project-scoped record back to the bucket that
142
+ // matches its own projectPath, deduping so the copy with real run history wins. A bucket activeId
143
+ // that pointed at a relocated record is cleared. Sentinel scope buckets (@manager/@company)
144
+ // legitimately hold records whose projectPath points at a project, so they are left untouched.
145
+ function migrateProjectBuckets(store) {
146
+ const projects = {};
147
+ const ensure = (key) => {
148
+ if (!projects[key])
149
+ projects[key] = { activeId: null, conversations: [] };
150
+ return projects[key];
151
+ };
152
+ // Preserve every existing bucket key and its activeId up front.
153
+ for (const [key, state] of Object.entries(store.projects || {})) {
154
+ ensure(key).activeId = state.activeId ?? null;
155
+ }
156
+ for (const [bucketKey, state] of Object.entries(store.projects || {})) {
157
+ const isSentinel = bucketKey === exports.MANAGER_SCOPE_KEY || bucketKey === exports.COMPANY_SCOPE_KEY;
158
+ for (const conv of (state.conversations || [])) {
159
+ if (!conv || typeof conv !== 'object')
160
+ continue;
161
+ const scope = conv.scope
162
+ ?? conv.invokedArea;
163
+ const ownPath = typeof conv.projectPath === 'string'
164
+ ? conv.projectPath
165
+ : '';
166
+ const mismatched = !isSentinel
167
+ && scope !== 'manager' && scope !== 'company'
168
+ && ownPath.length > 0
169
+ && canonicalProjectPathKey(ownPath) !== canonicalProjectPathKey(bucketKey);
170
+ if (!mismatched) {
171
+ placeConversationInBucket(ensure(bucketKey), conv);
172
+ continue;
173
+ }
174
+ // Relocate to the record's own project bucket.
175
+ placeConversationInBucket(ensure(path_1.default.resolve(ownPath)), conv);
176
+ }
177
+ }
178
+ // Any activeId that no longer resolves to a conversation in its bucket (because that record was
179
+ // relocated out) is stale — clear it so a project never points at another project's run.
180
+ for (const state of Object.values(projects)) {
181
+ if (state.activeId && !state.conversations.some((entry) => entry && entry.id === state.activeId)) {
182
+ state.activeId = null;
183
+ }
184
+ }
185
+ return { version: 1, projects };
186
+ }
187
+ // The full on-read migration pipeline, exported so a one-off maintenance/cleanup pass can heal an
188
+ // existing store file through the EXACT same code path the store uses at runtime (rather than a
189
+ // duplicated, drift-prone reimplementation). Operates on raw records, so it never drops a record
190
+ // via normalization.
191
+ function migrateConversationStore(store) {
192
+ return migrateProjectBuckets(migrateScopeBuckets(store));
193
+ }
96
194
  function normalizeConversation(projectPath, raw) {
97
195
  if (!raw || typeof raw !== 'object')
98
196
  return null;
@@ -242,8 +340,10 @@ class AiHubConversationStore {
242
340
  if (raw.version !== 1 || !raw.projects || typeof raw.projects !== 'object') {
243
341
  return { version: 1, projects: {} };
244
342
  }
245
- // Issue #708: fold legacy manager/company-invoked runs into scope buckets on read.
246
- return migrateScopeBuckets({ version: 1, projects: raw.projects });
343
+ // Issue #708: fold legacy manager/company-invoked runs into scope buckets on read, then
344
+ // relocate any project-scoped record mis-filed under the wrong project bucket back to its
345
+ // own project (see docs/rca/hub-conversation-cross-project-leak.md).
346
+ return migrateProjectBuckets(migrateScopeBuckets({ version: 1, projects: raw.projects }));
247
347
  }
248
348
  catch {
249
349
  return { version: 1, projects: {} };
@@ -140,7 +140,9 @@ class AiHubPreferencesStore {
140
140
  ? raw.recentJobInstructions
141
141
  : {},
142
142
  personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
143
- apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
143
+ // Issue #750: apiKey is no longer a persisted field here at all — any
144
+ // legacy apiKey left over from a pre-#750 ai-hub-state.json is silently
145
+ // dropped, never read back. Identity comes from ~/.fraim/config.json only.
144
146
  projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath, { removedProjectPaths }),
145
147
  removedProjectPaths,
146
148
  };
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
40
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
41
+ exports.buildOpenFileInvocation = buildOpenFileInvocation;
41
42
  exports.findAvailablePort = findAvailablePort;
42
43
  exports.findAvailablePortExcluding = findAvailablePortExcluding;
43
44
  const express_1 = __importDefault(require("express"));
@@ -61,6 +62,7 @@ const conversation_store_1 = require("./conversation-store");
61
62
  const remote_hub_gateway_1 = require("./remote-hub-gateway");
62
63
  const managed_browser_1 = require("./managed-browser");
63
64
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
65
+ const user_config_1 = require("../cli/utils/user-config");
64
66
  let personaHiringModule;
65
67
  let managerHiringModule;
66
68
  function loadPersonaHiringModule() {
@@ -973,28 +975,43 @@ function resolveSafeArtifactPath(rawPath, projectPath) {
973
975
  ];
974
976
  return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
975
977
  }
976
- function hubOpenFile(filePath) {
977
- return new Promise((resolve, reject) => {
978
- const args = process.platform === 'win32'
979
- ? [
978
+ // Builds the OS "open this file with its default app" invocation. Exported for regression
979
+ // testing (see tests/isolated/test-ai-hub-open-file.ts).
980
+ //
981
+ // Windows contract: the path travels via an ENVIRONMENT VARIABLE, never as a trailing `-Command`
982
+ // argument. PowerShell re-parses trailing args on its own command line and mangles Windows paths —
983
+ // it strips backslashes and truncates at the first space — so `Invoke-Item -LiteralPath $p`
984
+ // received a broken path (e.g. `C:UserssidmaOneDriveCodeDrug`) and failed for every real artifact
985
+ // path, which is why the Hub's "open file" button did nothing. `$env:FRAIM_OPEN_PATH` is read
986
+ // directly from the environment and is not re-parsed, so the path arrives intact (verified against
987
+ // paths containing spaces and backslashes).
988
+ function buildOpenFileInvocation(filePath, platform = process.platform) {
989
+ if (platform === 'win32') {
990
+ return {
991
+ command: 'powershell.exe',
992
+ args: [
980
993
  '-NoProfile',
981
994
  '-ExecutionPolicy',
982
995
  'Bypass',
983
996
  '-Command',
984
- '& { param($p) try { Invoke-Item -LiteralPath $p; exit 0 } catch { Write-Error $_; exit 1 } }',
985
- filePath,
986
- ]
987
- : process.platform === 'darwin'
988
- ? [filePath]
989
- : [filePath];
990
- const command = process.platform === 'win32'
991
- ? 'powershell.exe'
992
- : process.platform === 'darwin'
993
- ? 'open'
994
- : 'xdg-open';
997
+ 'try { Invoke-Item -LiteralPath $env:FRAIM_OPEN_PATH; exit 0 } catch { Write-Error $_; exit 1 }',
998
+ ],
999
+ envPatch: { FRAIM_OPEN_PATH: filePath },
1000
+ };
1001
+ }
1002
+ return {
1003
+ command: platform === 'darwin' ? 'open' : 'xdg-open',
1004
+ args: [filePath],
1005
+ envPatch: {},
1006
+ };
1007
+ }
1008
+ function hubOpenFile(filePath) {
1009
+ return new Promise((resolve, reject) => {
1010
+ const { command, args, envPatch } = buildOpenFileInvocation(filePath);
995
1011
  const child = (0, child_process_1.spawn)(command, args, {
996
1012
  stdio: ['ignore', 'ignore', 'pipe'],
997
1013
  windowsHide: process.platform === 'win32',
1014
+ env: { ...process.env, ...envPatch },
998
1015
  });
999
1016
  let stderr = '';
1000
1017
  child.stderr?.on('data', (chunk) => {
@@ -1051,53 +1068,15 @@ function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath
1051
1068
  // ---------------------------------------------------------------------------
1052
1069
  // Issue #512 (S3) — Hub bootstrap projection helpers.
1053
1070
  // ---------------------------------------------------------------------------
1054
- // Resolve the user email used to key personal (L1) learnings. Reads
1055
- // ~/.fraim/preferences.json (the existing user-prefs file) when present so the
1056
- // Brain counts match the auto-load context; falls back to a sensible default
1057
- // that resolveLearningUserId can refine against on-disk files.
1058
- function getHubUserEmail() {
1059
- try {
1060
- const prefsPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'preferences.json');
1061
- if (fs_1.default.existsSync(prefsPath)) {
1062
- const raw = JSON.parse(fs_1.default.readFileSync(prefsPath, 'utf8'));
1063
- if (typeof raw.userEmail === 'string' && raw.userEmail.length > 0)
1064
- return raw.userEmail;
1065
- }
1066
- }
1067
- catch {
1068
- // fall through to default
1069
- }
1070
- // #533: preferences.json may not carry the email yet — the proxy persists it on
1071
- // fraim_connect (see stdio-server). Until then, personal learnings are ALWAYS
1072
- // stamped `<email>-<type>.md`, so a single-user machine resolves deterministically
1073
- // from the on-disk files. This is what makes the avatar + personal learnings work.
1074
- const stamped = resolveSingleStampedUser();
1075
- if (stamped)
1076
- return stamped;
1077
- return 'fraim-user';
1078
- }
1079
- // Return the one user prefix stamped on the global learning files, or null if
1080
- // there is not exactly one (so we never guess between multiple accounts).
1081
- function resolveSingleStampedUser() {
1082
- try {
1083
- const dir = (0, project_fraim_paths_1.getUserFraimLearningsDir)();
1084
- if (!fs_1.default.existsSync(dir))
1085
- return null;
1086
- const prefixes = new Set();
1087
- for (const f of fs_1.default.readdirSync(dir)) {
1088
- if (f.startsWith('org-'))
1089
- continue;
1090
- const m = f.match(/^(.*)-(preferences|manager-coaching|mistake-patterns|validated-patterns)\.md$/);
1091
- if (m)
1092
- prefixes.add(m[1]);
1093
- }
1094
- if (prefixes.size === 1)
1095
- return Array.from(prefixes)[0];
1096
- }
1097
- catch {
1098
- // ignore unreadable dir
1099
- }
1100
- return null;
1071
+ // Issue #750: the Hub's identity for persona/entitlement resolution, the
1072
+ // displayed "signed in as" email, and personal (L1) learnings keying comes
1073
+ // from ~/.fraim/config.json's apiKey (architecture.md §4.3.4's "Global config
1074
+ // stores only identity"), the same credential every other FRAIM CLI/MCP
1075
+ // surface already reads via readUserFraimConfig(). There is no fallback: a
1076
+ // missing or invalid apiKey resolves to null (rendered as a "not connected"
1077
+ // state), never a guess from another local file.
1078
+ function resolveApiKey() {
1079
+ return (0, user_config_1.readUserFraimConfig)().apiKey;
1101
1080
  }
1102
1081
  // Read persisted Get-started step states from ~/.fraim/install-state.json
1103
1082
  // (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
@@ -1177,21 +1156,13 @@ class AiHubServer {
1177
1156
  explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
1178
1157
  });
1179
1158
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
1180
- // Issue #701: the AI Hub is a loopback companion that never touches MongoDB directly
1181
- // on every machine (dev, CI, prod) it resolves persona/manager-team state from the hosted
1182
- // server through the same code path. The hosted server is the sole owner of DB access.
1183
- // A dbService is used ONLY when explicitly injected (e.g. the hosted server embedding the
1184
- // Hub, or a test); it is never auto-created, so there is no dev/prod divergence.
1185
- this.dbService = options.dbService;
1186
- this.ownsDbService = false;
1159
+ // Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
1160
+ // never touches a database. Persona and manager-team state resolve from the hosted server
1161
+ // through the remote gateway; the hosted server is the sole owner of DB access.
1187
1162
  this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
1188
1163
  this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1189
1164
  this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1190
1165
  this.app.use(express_1.default.json({ limit: '10mb' }));
1191
- if (this.dbService) {
1192
- const { registerPaymentRoutes } = require('../routes/payment-routes');
1193
- registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
1194
- }
1195
1166
  // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
1196
1167
  // calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
1197
1168
  this.app.use((_req, res, next) => {
@@ -1207,8 +1178,7 @@ class AiHubServer {
1207
1178
  });
1208
1179
  // Payment success redirect: sync entitlement then bounce to the hub.
1209
1180
  // Stripe redirects here after a completed persona-hire checkout.
1210
- this.app.get('/ai-hub/payment-success', async (req, res) => {
1211
- const sessionId = req.query['session_id'];
1181
+ this.app.get('/ai-hub/payment-success', (req, res) => {
1212
1182
  const email = req.query['email'];
1213
1183
  // Persist the buyer's email so bootstrap can look up entitlements by userId.
1214
1184
  if (email) {
@@ -1223,17 +1193,8 @@ class AiHubServer {
1223
1193
  console.warn('[ai-hub] could not persist userEmail:', err);
1224
1194
  }
1225
1195
  }
1226
- if (sessionId && this.dbService) {
1227
- try {
1228
- const { syncPersonaEntitlementFromCheckoutSession } = await Promise.resolve().then(() => __importStar(require('../services/persona-entitlement-service')));
1229
- const { stripe } = await Promise.resolve().then(() => __importStar(require('../config/stripe')));
1230
- const session = await stripe.checkout.sessions.retrieve(sessionId);
1231
- await syncPersonaEntitlementFromCheckoutSession(this.dbService, session, 'dashboard-link');
1232
- }
1233
- catch (err) {
1234
- console.warn('[ai-hub] payment-success entitlement sync failed:', err);
1235
- }
1236
- }
1196
+ // Issue #749: the Hub holds no DB. Entitlement is synced authoritatively by the backend
1197
+ // Stripe webhook; the Hub re-reads status via the remote gateway on the next bootstrap.
1237
1198
  res.redirect('/ai-hub/?hired=1');
1238
1199
  });
1239
1200
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
@@ -1322,20 +1283,6 @@ class AiHubServer {
1322
1283
  }
1323
1284
  async start(port) {
1324
1285
  this.httpPort = port;
1325
- if (this.dbService) {
1326
- try {
1327
- await this.dbService.connect();
1328
- const mongoClient = this.dbService.getClient();
1329
- if (mongoClient) {
1330
- const PaymentRepositoryImpl = require('../db/payment-repository').PaymentRepository;
1331
- this.paymentRepo = new PaymentRepositoryImpl(mongoClient);
1332
- }
1333
- }
1334
- catch (err) {
1335
- console.warn('[ai-hub] DB connect failed — personas will show as locked:', err);
1336
- this.dbService = undefined;
1337
- }
1338
- }
1339
1286
  await new Promise((resolve, reject) => {
1340
1287
  this.httpServer = this.app.listen(port, '127.0.0.1');
1341
1288
  this.httpServer.once('listening', () => resolve());
@@ -1406,10 +1353,6 @@ class AiHubServer {
1406
1353
  // #521: tear down the shared browser if WE launched it (stop() no-ops on a
1407
1354
  // browser the manager owns).
1408
1355
  this.managedBrowser.stop();
1409
- if (this.ownsDbService && this.dbService) {
1410
- await this.dbService.close();
1411
- this.dbService = undefined;
1412
- }
1413
1356
  }
1414
1357
  getHttpsPort() { return this.httpsPort; }
1415
1358
  knownProjects(projectPath, extras = []) {
@@ -1422,7 +1365,7 @@ class AiHubServer {
1422
1365
  ...extras,
1423
1366
  ], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
1424
1367
  }
1425
- async bootstrapResponse(projectPath, apiKey) {
1368
+ async bootstrapResponse(projectPath) {
1426
1369
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
1427
1370
  const employees = this.hostRuntime.detectEmployees();
1428
1371
  let preferences = this.preferencesStore.load(normalizedProjectPath);
@@ -1449,9 +1392,12 @@ class AiHubServer {
1449
1392
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
1450
1393
  }));
1451
1394
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
1452
- const resolvedApiKey = apiKey || preferences.apiKey;
1395
+ // Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
1396
+ // override, no ai-hub-state.json copy, no fallback chain.
1397
+ const resolvedApiKey = resolveApiKey();
1453
1398
  const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
1454
1399
  const managerTeam = await this.computeManagerTeam(resolvedApiKey);
1400
+ const resolvedUserEmail = userKey ?? null;
1455
1401
  const projects = this.knownProjects(normalizedProjectPath);
1456
1402
  preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
1457
1403
  this.preferencesStore.save(preferences);
@@ -1464,7 +1410,11 @@ class AiHubServer {
1464
1410
  title: 'AI Hub',
1465
1411
  remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
1466
1412
  project,
1467
- preferences,
1413
+ // Issue #750: `apiKey` is no longer a persisted AiHubPreferences field —
1414
+ // it is overlaid on the wire response only, freshly resolved from
1415
+ // ~/.fraim/config.json, so public/ai-hub/script.js's existing
1416
+ // tfConnectedApiKey()/tfConnectedSurfaceUrl read path needs no changes.
1417
+ preferences: { ...preferences, apiKey: resolvedApiKey },
1468
1418
  categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
1469
1419
  jobs,
1470
1420
  managerTemplates,
@@ -1476,10 +1426,11 @@ class AiHubServer {
1476
1426
  // Issue #512 (S3) — additive manager-flow projections.
1477
1427
  firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
1478
1428
  teamContext: this.computeTeamContext(normalizedProjectPath),
1479
- brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length),
1480
- // #533: the resolved account email, so the profile card shows the real
1481
- // identity (not a hardcoded placeholder) and the client can display it.
1482
- userEmail: getHubUserEmail(),
1429
+ brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length, resolvedUserEmail),
1430
+ // #533/#750: the resolved account email (from the same ~/.fraim/config.json
1431
+ // apiKey used for personas above), or null when not connected — so the
1432
+ // profile card and personas can never disagree about who's signed in.
1433
+ userEmail: resolvedUserEmail,
1483
1434
  // #744: the org cobrand identity (name/color/logo) from the org context
1484
1435
  // storage, or null when unset so the Hub falls back to FRAIM identity.
1485
1436
  orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
@@ -1866,8 +1817,19 @@ class AiHubServer {
1866
1817
  }
1867
1818
  // Issue #512 (S3, R14) — Brain summary: preserved-learning counts by scope +
1868
1819
  // registry catalog counts. A read projection; no new storage.
1869
- computeBrain(projectPath, jobCount) {
1870
- const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, getHubUserEmail());
1820
+ // Issue #750: `userEmail` is the config.json-resolved identity, or `null` when
1821
+ // not connected. `organization`/`rawSignals` are identity-independent (org-*
1822
+ // files and repo-level raw signals) and must always be counted regardless of
1823
+ // connection state — only `manager`/`project` (L1, individual-keyed) are
1824
+ // zeroed when not connected, rather than guessing an identity via
1825
+ // resolveLearningUserId's single-stamped-user fallback (a legitimate
1826
+ // convenience for MCP auto-load context, but not appropriate here).
1827
+ computeBrain(projectPath, jobCount, userEmail) {
1828
+ const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
1829
+ if (!userEmail) {
1830
+ learnings.manager = 0;
1831
+ learnings.project = 0;
1832
+ }
1871
1833
  return {
1872
1834
  learnings,
1873
1835
  catalog: {
@@ -2053,51 +2015,48 @@ class AiHubServer {
2053
2015
  // Issue #701: manager team comes from the hosted server, not local Mongo.
2054
2016
  return this.remoteGateway.listManagerTeam(apiKey);
2055
2017
  }
2018
+ // Issue #750: identity for routes that don't already call computePersonas()
2019
+ // (which fetches this same hosted state for personas/manager-team). Always
2020
+ // sources the apiKey from ~/.fraim/config.json; null means "not connected" —
2021
+ // no local-guess fallback.
2022
+ async resolveHubIdentity() {
2023
+ const apiKey = resolveApiKey();
2024
+ if (!apiKey)
2025
+ return null;
2026
+ const state = await this.remoteGateway.getPersonaState(apiKey);
2027
+ return state?.userId ?? null;
2028
+ }
2029
+ // Issue #750: shared by the two learnings routes below. 'org' scope never
2030
+ // needs identity (readPreservedLearnings/applyLearningEntryChange ignore the
2031
+ // userId param for it); every other scope resolves through resolveHubIdentity,
2032
+ // returning '' (not a guess) when not connected — callers distinguish
2033
+ // "org" from "not connected" via the scope check they already have to make.
2034
+ async resolveLearningIdentity(scope) {
2035
+ if (scope === 'org')
2036
+ return '';
2037
+ return (await this.resolveHubIdentity()) || '';
2038
+ }
2056
2039
  registerRoutes() {
2057
- // Issue #512: Serve the account and analytics pages from public/.
2058
- // These live outside of /ai-hub so they need explicit routes.
2059
- const publicDir = resolveAiHubPublicDir().replace(/[\\/]ai-hub$/, '');
2060
- if (!this.dbService) {
2061
- this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
2062
- const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
2063
- if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
2064
- const rawSurface = parsed.searchParams.get('surface');
2065
- const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
2066
- ? rawSurface
2067
- : 'analytics';
2068
- parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
2069
- }
2070
- res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
2071
- });
2072
- this.app.get(['/account', '/account/'], (req, res) => {
2073
- res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
2074
- });
2075
- this.app.get(['/analytics', '/analytics/'], (req, res) => {
2076
- res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
2077
- });
2078
- }
2079
- else {
2080
- this.app.get('/account', (_req, res) => {
2081
- const filePath = path_1.default.join(publicDir, 'account', 'index.html');
2082
- if (fs_1.default.existsSync(filePath)) {
2083
- res.sendFile(filePath);
2084
- }
2085
- else {
2086
- res.status(404).send('Account page not found.');
2087
- }
2088
- });
2089
- this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
2090
- this.app.get('/analytics', (_req, res) => {
2091
- const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
2092
- if (fs_1.default.existsSync(filePath)) {
2093
- res.sendFile(filePath);
2094
- }
2095
- else {
2096
- res.status(404).send('Analytics page not found.');
2097
- }
2098
- });
2099
- this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
2100
- }
2040
+ // Issue #512 / #749: the account and analytics surfaces live outside /ai-hub. The
2041
+ // on-machine Hub holds no DB, so it always redirects these (and /auth) to the hosted
2042
+ // server, which owns the authenticated, DB-backed pages.
2043
+ this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
2044
+ const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
2045
+ if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
2046
+ const rawSurface = parsed.searchParams.get('surface');
2047
+ const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
2048
+ ? rawSurface
2049
+ : 'analytics';
2050
+ parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
2051
+ }
2052
+ res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
2053
+ });
2054
+ this.app.get(['/account', '/account/'], (req, res) => {
2055
+ res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
2056
+ });
2057
+ this.app.get(['/analytics', '/analytics/'], (req, res) => {
2058
+ res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
2059
+ });
2101
2060
  // Issue #478: Serve the PowerPoint task pane HTML and manifest.
2102
2061
  // Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
2103
2062
  // request, so we must strip them before resolving the file path. Use a custom
@@ -2149,8 +2108,6 @@ class AiHubServer {
2149
2108
  }
2150
2109
  catch { }
2151
2110
  }
2152
- // Read API key from header — query-param API keys are prohibited (§3.14)
2153
- const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2154
2111
  // #719: a bare reload (no projectPath query) must land on the last-recorded
2155
2112
  // workspace project, not the launch folder. The bootstrap saves its resolved
2156
2113
  // current path back into the projects list, so defaulting to the launch
@@ -2164,17 +2121,18 @@ class AiHubServer {
2164
2121
  projectPath = recorded;
2165
2122
  }
2166
2123
  }
2167
- res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
2124
+ res.json(await this.bootstrapResponse(projectPath || this.projectPath));
2168
2125
  });
2169
2126
  // Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
2170
2127
  // same projection folded into bootstrap. Useful for the avatar→Brain view
2171
2128
  // without re-fetching the whole bootstrap payload.
2172
- this.app.get('/api/ai-hub/brain', (req, res) => {
2129
+ this.app.get('/api/ai-hub/brain', async (req, res) => {
2173
2130
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2174
2131
  ? path_1.default.resolve(req.query.projectPath)
2175
2132
  : this.projectPath;
2176
2133
  const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath).length + (0, catalog_1.discoverManagerTemplates)(projectPath).length;
2177
- return res.json(this.computeBrain(projectPath, jobCount));
2134
+ const userEmail = await this.resolveHubIdentity();
2135
+ return res.json(this.computeBrain(projectPath, jobCount, userEmail));
2178
2136
  });
2179
2137
  // #533: read the PRESERVED learnings for a section + storage level so the
2180
2138
  // Company/Manager sections (machine level) and the project workspace (project
@@ -2187,7 +2145,7 @@ class AiHubServer {
2187
2145
  : (typeof (req.body && req.body.projectPath) === 'string' && req.body.projectPath.length > 0
2188
2146
  ? path_1.default.resolve(req.body.projectPath)
2189
2147
  : this.projectPath);
2190
- this.app.get('/api/ai-hub/learnings', (req, res) => {
2148
+ this.app.get('/api/ai-hub/learnings', async (req, res) => {
2191
2149
  const scope = req.query.scope;
2192
2150
  if (typeof scope !== 'string' || !VALID_SCOPES.includes(scope)) {
2193
2151
  return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
@@ -2195,7 +2153,11 @@ class AiHubServer {
2195
2153
  const level = (typeof req.query.level === 'string' && VALID_LEVELS.includes(req.query.level))
2196
2154
  ? req.query.level : 'machine';
2197
2155
  try {
2198
- const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), getHubUserEmail(), scope, level);
2156
+ const userEmail = await this.resolveLearningIdentity(scope);
2157
+ if (scope !== 'org' && !userEmail) {
2158
+ return res.json({ scope, level, entries: [] });
2159
+ }
2160
+ const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), userEmail, scope, level);
2199
2161
  return res.json({ scope, level, entries });
2200
2162
  }
2201
2163
  catch (error) {
@@ -2205,7 +2167,7 @@ class AiHubServer {
2205
2167
  // #533 §3: add / edit / delete a single learning entry — writes the real file
2206
2168
  // at the section's level (machine for the Manager/Company tabs, project for the
2207
2169
  // project workspace). The action targets exactly the card's file.
2208
- this.app.post('/api/ai-hub/learnings/entry', (req, res) => {
2170
+ this.app.post('/api/ai-hub/learnings/entry', async (req, res) => {
2209
2171
  const b = (req.body || {});
2210
2172
  if (!['add', 'edit', 'delete'].includes(b.action || '')) {
2211
2173
  return res.status(400).json({ error: 'action must be one of: add, edit, delete' });
@@ -2216,8 +2178,14 @@ class AiHubServer {
2216
2178
  return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
2217
2179
  const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
2218
2180
  const ref = { scope: b.scope, level, category: b.category };
2181
+ // Issue #750: writes to a personal-scope file need a resolved identity —
2182
+ // fail explicitly rather than writing to a guessed or empty-string file.
2183
+ const userEmail = await this.resolveLearningIdentity(b.scope);
2184
+ if (b.scope !== 'org' && !userEmail) {
2185
+ return res.status(400).json({ error: 'not_connected', message: 'No FRAIM identity resolved. Run `fraim setup` first.' });
2186
+ }
2219
2187
  try {
2220
- const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), getHubUserEmail(), ref, b.action, {
2188
+ const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), userEmail, ref, b.action, {
2221
2189
  originalTitle: b.originalTitle,
2222
2190
  severity: b.severity,
2223
2191
  title: b.title,
@@ -2229,24 +2197,22 @@ class AiHubServer {
2229
2197
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
2230
2198
  }
2231
2199
  });
2232
- // Issue #540/#701: POST /api/ai-hub/manager-team/assign
2233
- // Proxies to the hosted server (which owns the DB + seat enforcement). The Hub
2234
- // accepts the local X-Fraim-Api-Key header and forwards it as the hosted x-api-key.
2200
+ // Issue #540/#701/#750: POST /api/ai-hub/manager-team/assign
2201
+ // Proxies to the hosted server (which owns the DB + seat enforcement), using
2202
+ // the Hub's own ~/.fraim/config.json apiKey not a caller-supplied header.
2235
2203
  // Hosted returns 404 no_company_seat / 409 out_of_stock; those are passed through.
2236
2204
  this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
2237
- const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2238
2205
  const { personaKey } = (req.body ?? {});
2239
2206
  if (!personaKey)
2240
2207
  return res.status(400).json({ error: 'personaKey required' });
2241
- const result = await this.remoteGateway.assignManagerTeam(apiKey, personaKey);
2208
+ const result = await this.remoteGateway.assignManagerTeam(resolveApiKey(), personaKey);
2242
2209
  return res.status(result.status).json(result.body);
2243
2210
  });
2244
- // Issue #540/#701: DELETE /api/ai-hub/manager-team/assign/:personaKey
2211
+ // Issue #540/#701/#750: DELETE /api/ai-hub/manager-team/assign/:personaKey
2245
2212
  // Proxies the seat release to the hosted server.
2246
2213
  this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
2247
- const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2248
2214
  const { personaKey } = req.params;
2249
- await this.remoteGateway.removeManagerTeam(apiKey, personaKey);
2215
+ await this.remoteGateway.removeManagerTeam(resolveApiKey(), personaKey);
2250
2216
  return res.status(204).end();
2251
2217
  });
2252
2218
  // Issue #708: manager/company scopes resolve to a project-independent sentinel bucket
@@ -2277,7 +2243,27 @@ class AiHubServer {
2277
2243
  // deletes are honored (a conversation absent here is dropped).
2278
2244
  const prior = this.conversationStore.loadProject(key);
2279
2245
  const priorById = new Map(prior.conversations.map((entry) => [entry.id, entry]));
2280
- const conversations = body.conversations.map((incoming) => {
2246
+ // Write-boundary guard for the cross-project leak (docs/rca/hub-conversation-cross-project-leak.md):
2247
+ // a project PUT persists the client's current-project list, but that list can transiently
2248
+ // include a conversation belonging to ANOTHER project (e.g. the just-finished run of the
2249
+ // project the user switched away from). Never file such a record into this project bucket —
2250
+ // its authoritative home is its own project bucket. Scope (manager/company) PUTs are exempt:
2251
+ // their sentinel buckets legitimately hold records whose projectPath points at a project.
2252
+ const belongsInBucket = (incoming) => {
2253
+ if (scope)
2254
+ return true;
2255
+ const incomingScope = incoming.scope
2256
+ ?? incoming.invokedArea;
2257
+ if (incomingScope === 'manager' || incomingScope === 'company')
2258
+ return false;
2259
+ const own = incoming && typeof incoming.projectPath === 'string' ? incoming.projectPath : '';
2260
+ if (!own)
2261
+ return true;
2262
+ return normalizedDirectoryPath(own) === normalizedDirectoryPath(key);
2263
+ };
2264
+ const conversations = body.conversations
2265
+ .filter((incoming) => belongsInBucket(incoming))
2266
+ .map((incoming) => {
2281
2267
  const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
2282
2268
  return existing ? { ...existing, ...incoming } : incoming;
2283
2269
  });
@@ -2375,14 +2361,9 @@ class AiHubServer {
2375
2361
  const projects = this.preferencesStore.removeProject(projectPath, known.filter((project) => project.id !== entry.id), entry.folderPath);
2376
2362
  return res.json({ ok: true, projects });
2377
2363
  });
2378
- this.app.post('/api/ai-hub/api-key', (req, res) => {
2379
- const { apiKey } = req.body;
2380
- if (!apiKey || typeof apiKey !== 'string')
2381
- return res.status(400).json({ error: 'apiKey required' });
2382
- const prefs = this.preferencesStore.load(this.projectPath);
2383
- this.preferencesStore.save({ ...prefs, apiKey });
2384
- return res.json({ ok: true });
2385
- });
2364
+ // Issue #750: POST /api/ai-hub/api-key is removed there is no Hub-local
2365
+ // apiKey to persist. Identity always comes from ~/.fraim/config.json
2366
+ // (set by `fraim setup`), read fresh via resolveApiKey() on every request.
2386
2367
  this.app.post('/api/ai-hub/preferences', (req, res) => {
2387
2368
  const { personaKey } = req.body;
2388
2369
  const prefs = this.preferencesStore.load(this.projectPath);
@@ -58,6 +58,16 @@ function getApiKeyForLogging(req) {
58
58
  }
59
59
  return 'ANONYMOUS';
60
60
  }
61
+ function getBodyForLogging(req) {
62
+ if (req.path === '/api/ai-hub/brand' && req.body && typeof req.body === 'object') {
63
+ const body = { ...req.body };
64
+ if (typeof body.logo === 'string' && body.logo.length > 0) {
65
+ body.logo = `[redacted logo payload, ${body.logo.length} chars]`;
66
+ }
67
+ return JSON.stringify(body);
68
+ }
69
+ return JSON.stringify(req.body);
70
+ }
61
71
  /**
62
72
  * Middleware to log all requests
63
73
  */
@@ -82,7 +92,7 @@ function requestLogger(req, res, next) {
82
92
  let params = '';
83
93
  if (method === 'POST') {
84
94
  try {
85
- params = JSON.stringify(req.body);
95
+ params = getBodyForLogging(req);
86
96
  }
87
97
  catch (e) {
88
98
  params = '[Unserializable Body]';
@@ -61,7 +61,9 @@ class TelemetryMiddleware {
61
61
  // `fraim sync` CLI, which runs outside any MCP session (same lifecycle
62
62
  // position as /api/registry/sync). Auth still applies; only the
63
63
  // session-active gate is exempted.
64
- const exemptPaths = ['/health', '/admin', '/api/registry/sync', '/api/registry/github-workflows', '/api/registry/topology/personalized', '/api/registry/topology/usage-heatmap', '/api/org/', '/api/manager/', '/api/payment/', '/api/sales/', '/api/signup', '/api/providers', '/api/analytics', '/analytics', '/api/auth/', '/api/account/'];
64
+ // Issue #701/#744 - hosted AI Hub HTTP APIs are browser/gateway routes,
65
+ // not MCP tool calls. They are still authenticated by API key/session.
66
+ const exemptPaths = ['/health', '/admin', '/api/registry/sync', '/api/registry/github-workflows', '/api/registry/topology/personalized', '/api/registry/topology/usage-heatmap', '/api/org/', '/api/manager/', '/api/ai-hub/', '/api/payment/', '/api/sales/', '/api/signup', '/api/providers', '/api/analytics', '/analytics', '/api/auth/', '/api/account/'];
65
67
  if (exemptPaths.some(p => req.path.startsWith(p))) {
66
68
  return next();
67
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.198",
3
+ "version": "2.0.200",
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": {
@@ -9631,23 +9631,15 @@ async function tfHandleConnectedAuthMessage(event) {
9631
9631
  }
9632
9632
  }
9633
9633
 
9634
+ // Issue #750: the Hub's identity comes from ~/.fraim/config.json (set by
9635
+ // `fraim setup`), not a Hub-local copy — there is nothing to persist here.
9636
+ // A key recovered via embedded sign-in is used for the rest of this browser
9637
+ // session only (in-memory); it does not survive a Hub restart, by design.
9634
9638
  async function tfRememberConnectedApiKey(apiKey) {
9635
- const changed = apiKey !== state.storedApiKey;
9636
9639
  state.storedApiKey = apiKey;
9637
9640
  if (state.bootstrap && state.bootstrap.preferences) {
9638
9641
  state.bootstrap.preferences.apiKey = apiKey;
9639
9642
  }
9640
- if (changed) {
9641
- try {
9642
- await requestJson('/api/ai-hub/api-key', {
9643
- method: 'POST',
9644
- headers: { 'Content-Type': 'application/json' },
9645
- body: JSON.stringify({ apiKey }),
9646
- });
9647
- } catch (error) {
9648
- console.warn('[ai-hub] Could not persist connected auth key:', error);
9649
- }
9650
- }
9651
9643
  }
9652
9644
 
9653
9645
  // ---------------------------------------------------------------------------
@@ -10352,6 +10344,12 @@ async function tfSwitchProjectFolder(folderPath) {
10352
10344
  return;
10353
10345
  }
10354
10346
  state.projectPath = normalized.folderPath;
10347
+ // Cross-project leak fix (docs/rca/hub-conversation-cross-project-leak.md): drop the previous
10348
+ // project's active conversation on switch. Otherwise the just-finished run stays state.activeId
10349
+ // while state.projectPath is the new project, and a debounced conversation PUT during the
10350
+ // cached-bucket fast render below would flush that foreign conversation into the new project's
10351
+ // bucket. hydrateConversationsFromServer recomputes the correct activeId for this project.
10352
+ state.activeId = null;
10355
10353
  // #521 fix: the context cache (state._ctxCache) is keyed by context key only
10356
10354
  // (projectContext/projectBrief/projectRules), NOT by project — so without this
10357
10355
  // the new project's Brief section would render the PREVIOUS project's content
@@ -10850,6 +10848,12 @@ function tfWireShell() {
10850
10848
  // replacing the hardcoded "SM" placeholder. Falls back gracefully when the email
10851
10849
  // isn't known yet (e.g. before the proxy has persisted it).
10852
10850
  function tfAccountIdentity() {
10851
+ // Issue #750: bootstrap.userEmail is `null` (not just missing) once the server
10852
+ // has definitively resolved "no ~/.fraim/config.json apiKey" — distinct from
10853
+ // the transient pre-bootstrap window where state.bootstrap doesn't exist yet.
10854
+ if (state.bootstrap && state.bootstrap.userEmail === null) {
10855
+ return { email: '', name: 'You', initials: '·', notConnected: true };
10856
+ }
10853
10857
  const email = (state.bootstrap && state.bootstrap.userEmail) || '';
10854
10858
  if (!email || !email.includes('@')) {
10855
10859
  return { email: '', name: 'You', initials: '·' };
@@ -10869,7 +10873,7 @@ function tfPopulateAccountMenu() {
10869
10873
  const avEl = document.getElementById('am-av');
10870
10874
  const avatarBtn = document.getElementById('avatar-btn');
10871
10875
  if (nameEl) nameEl.textContent = id.name;
10872
- if (emailEl) emailEl.textContent = id.email;
10876
+ if (emailEl) emailEl.textContent = id.notConnected ? 'Not connected — run `fraim setup`' : id.email;
10873
10877
  if (avEl) avEl.textContent = id.initials;
10874
10878
  if (avatarBtn) avatarBtn.textContent = id.initials;
10875
10879
  }