@unifiedmemory/cli 1.3.12 → 1.3.14

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/lib/mcp-server.js CHANGED
@@ -11,28 +11,32 @@ import path from 'path';
11
11
  import os from 'os';
12
12
  import { loadAndRefreshToken } from './token-validation.js';
13
13
  import { fetchRemoteMCPTools, callRemoteMCPTool } from './mcp-proxy.js';
14
+ import { config } from './config.js';
15
+
16
+ /**
17
+ * Usage cache to avoid spamming quota API
18
+ * Map: orgId -> { data: QuotaResponse, timestamp: number }
19
+ */
20
+ const usageCache = new Map();
21
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
22
+ const UPGRADE_THRESHOLD = 90; // Show prompt at 90%+
14
23
 
15
24
  /**
16
25
  * Start the MCP server on stdio transport
17
26
  */
18
27
  export async function startMCPServer() {
19
28
  try {
20
- // 1. Load and validate authentication
21
- const authData = await loadAndValidateAuth();
29
+ // 1. Validate authentication at startup for immediate failure
30
+ await loadAndValidateAuth();
22
31
 
23
- // 2. Load project context from current directory
32
+ // 2. Load project context from current directory (this doesn't change)
24
33
  const projectContext = loadProjectContext();
25
34
 
26
- // 3. Build auth headers
27
- const authHeaders = buildAuthHeaders(authData, projectContext);
35
+ // Note: We don't cache authData here anymore. Instead, we reload it
36
+ // on each request to ensure we always have the current token state,
37
+ // even after token refreshes.
28
38
 
29
- // 4. Extract auth context for parameter injection
30
- const authContext = {
31
- decoded: authData.decoded,
32
- selectedOrg: authData.selectedOrg
33
- };
34
-
35
- // 5. Create MCP server
39
+ // 3. Create MCP server
36
40
  const server = new Server(
37
41
  {
38
42
  name: "unifiedmemory",
@@ -46,22 +50,25 @@ export async function startMCPServer() {
46
50
  }
47
51
  );
48
52
 
49
- // 6. Register handlers
53
+ // 4. Register handlers - reload auth on each request
50
54
  server.setRequestHandler(ListToolsRequestSchema, async () => {
55
+ const { authHeaders } = await loadFreshAuth(projectContext);
51
56
  return await handleListTools(authHeaders, projectContext);
52
57
  });
53
58
 
54
59
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
60
+ const { authHeaders, authContext } = await loadFreshAuth(projectContext);
55
61
  return await handleCallTool(request, authHeaders, authContext, projectContext);
56
62
  });
57
63
 
58
64
  // Register resource handlers for authentication context
59
65
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
66
+ // Reload auth for fresh context
67
+ const authData = await loadAndRefreshToken();
60
68
  const resources = [];
61
69
 
62
- // Add org_id resource
63
- const orgId = authData.selectedOrg?.id || authData.decoded?.sub;
64
- if (orgId) {
70
+ // Add org_id resource only if JWT has org context
71
+ if (authData.decoded?.o?.o_id) {
65
72
  resources.push({
66
73
  uri: "um://context/org_id",
67
74
  name: "Organization ID",
@@ -95,14 +102,17 @@ export async function startMCPServer() {
95
102
  });
96
103
 
97
104
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
105
+ // Reload auth for fresh context
106
+ const authData = await loadAndRefreshToken();
98
107
  const { uri } = request.params;
99
108
  console.error(`→ Reading resource: ${uri}`);
100
109
 
101
110
  switch(uri) {
102
111
  case "um://context/org_id": {
103
- const orgId = authData.selectedOrg?.id || authData.decoded?.sub || "";
112
+ // Only return org_id if JWT has org claims
113
+ const orgId = authData.decoded?.o?.o_id;
104
114
  if (!orgId) {
105
- throw new Error("Organization context not available");
115
+ throw new Error("Organization context not available (using personal account)");
106
116
  }
107
117
  return {
108
118
  contents: [{
@@ -193,6 +203,7 @@ function loadProjectContext() {
193
203
 
194
204
  /**
195
205
  * Build authentication headers for gateway requests
206
+ * Checks JWT claims first, falls back to selectedOrgId from login
196
207
  * @param {Object} authData - Token data
197
208
  * @param {Object|null} projectContext - Project config
198
209
  * @returns {Object} - Headers object
@@ -202,21 +213,22 @@ function buildAuthHeaders(authData, projectContext) {
202
213
  'Authorization': `Bearer ${authData.idToken || authData.accessToken}`,
203
214
  };
204
215
 
205
- // Add org context
206
- if (authData.selectedOrg) {
207
- headers['X-Org-Id'] = authData.selectedOrg.id;
208
- console.error(`✓ Organization: ${authData.selectedOrg.name} (${authData.selectedOrg.id})`);
209
- } else if (authData.decoded?.sub) {
210
- // Fallback to user ID for personal account
211
- headers['X-Org-Id'] = authData.decoded.sub;
212
- console.error(`✓ Using personal account (${authData.decoded.sub})`);
213
- }
214
-
215
- // Add user ID from JWT
216
+ // Add user ID from JWT (always present)
216
217
  if (authData.decoded?.sub) {
217
218
  headers['X-User-Id'] = authData.decoded.sub;
218
219
  }
219
220
 
221
+ // Add X-Org-Id - check JWT claims first, fall back to selectedOrgId
222
+ const orgId = authData.decoded?.o?.o_id || authData.selectedOrgId;
223
+ const orgName = authData.decoded?.o?.o_name || authData.selectedOrgName;
224
+
225
+ if (orgId) {
226
+ headers['X-Org-Id'] = orgId;
227
+ console.error(`✓ Organization: ${orgName || orgId}`);
228
+ } else {
229
+ console.error(`✓ Using personal account context`);
230
+ }
231
+
220
232
  // Add project context if available
221
233
  if (projectContext) {
222
234
  headers['X-Project-Id'] = projectContext.project_id;
@@ -226,6 +238,93 @@ function buildAuthHeaders(authData, projectContext) {
226
238
  return headers;
227
239
  }
228
240
 
241
+ /**
242
+ * Load fresh authentication data and build headers
243
+ * Called on each MCP request to ensure current token state
244
+ * Checks JWT claims first, falls back to selectedOrgId from login
245
+ * @param {Object|null} projectContext - Project config
246
+ * @returns {Promise<{authHeaders: Object, authContext: Object}>}
247
+ */
248
+ async function loadFreshAuth(projectContext) {
249
+ const authData = await loadAndRefreshToken();
250
+ const authHeaders = buildAuthHeaders(authData, projectContext);
251
+
252
+ // Build auth context - check JWT claims first, fall back to selectedOrgId
253
+ const jwtOrgId = authData.decoded?.o?.o_id;
254
+ const orgId = jwtOrgId || authData.selectedOrgId;
255
+ const orgName = authData.decoded?.o?.o_name || authData.selectedOrgName;
256
+ const orgRole = authData.decoded?.o?.o_role;
257
+
258
+ const authContext = {
259
+ decoded: authData.decoded,
260
+ orgContext: orgId ? {
261
+ id: orgId,
262
+ name: orgName,
263
+ role: orgRole,
264
+ } : null
265
+ };
266
+
267
+ return { authHeaders, authContext };
268
+ }
269
+
270
+ /**
271
+ * Check quota usage and return upgrade prompt if needed
272
+ * Uses caching to avoid API spam (5-min TTL)
273
+ */
274
+ async function checkAndGetUpgradePrompt(authHeaders, authContext) {
275
+ try {
276
+ // Use org from JWT claims, or userId for personal context
277
+ const orgId = authContext?.orgContext?.id || authContext?.decoded?.sub;
278
+ if (!orgId) return null;
279
+
280
+ // Check cache
281
+ const cached = usageCache.get(orgId);
282
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
283
+ return formatUpgradePrompt(cached.data, authContext);
284
+ }
285
+
286
+ // Fetch fresh data
287
+ const response = await fetch(`${config.apiEndpoint}/v1/quota/usage`, {
288
+ headers: authHeaders
289
+ });
290
+
291
+ if (!response.ok) {
292
+ console.error(`⚠️ Quota check failed: ${response.status}`);
293
+ return null; // Fail silently, don't break MCP
294
+ }
295
+
296
+ const data = await response.json();
297
+
298
+ // Update cache
299
+ usageCache.set(orgId, { data, timestamp: Date.now() });
300
+
301
+ return formatUpgradePrompt(data, authContext);
302
+
303
+ } catch (error) {
304
+ console.error(`⚠️ Quota check error: ${error.message}`);
305
+ return null; // Fail silently
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Format upgrade prompt if usage >= threshold
311
+ */
312
+ function formatUpgradePrompt(quotaData, authContext) {
313
+ const { percentage_used, used, total } = quotaData.usage;
314
+
315
+ if (percentage_used < UPGRADE_THRESHOLD) return null;
316
+
317
+ const isOrg = quotaData.quota_type === 'organization';
318
+ const contextName = isOrg
319
+ ? authContext?.orgContext?.name
320
+ : 'your account';
321
+
322
+ return {
323
+ type: "text",
324
+ text: `\n---\n⚠️ Quota Alert: ${contextName} is at ${percentage_used.toFixed(1)}% capacity (${used.toLocaleString()}/${total.toLocaleString()} queries).\nUpgrade your plan at https://unifiedmemory.ai/pricing to unlock more capacity.\n---`
325
+ };
326
+ }
327
+
229
328
  /**
230
329
  * Handle tools/list request
231
330
  * @param {Object} authHeaders - Auth headers
@@ -260,12 +359,18 @@ async function handleCallTool(request, authHeaders, authContext, projectContext)
260
359
  const result = await callRemoteMCPTool(name, args, authHeaders, authContext, projectContext);
261
360
  console.error(`✓ Tool executed successfully: ${name}`);
262
361
 
362
+ // Check if upgrade prompt needed (cached, 5-min TTL)
363
+ const upgradePrompt = await checkAndGetUpgradePrompt(authHeaders, authContext);
364
+
263
365
  return {
264
- content: result.content || [
265
- {
266
- type: "text",
267
- text: JSON.stringify(result, null, 2),
268
- },
366
+ content: [
367
+ ...(result.content || [
368
+ {
369
+ type: "text",
370
+ text: JSON.stringify(result, null, 2),
371
+ },
372
+ ]),
373
+ ...(upgradePrompt ? [upgradePrompt] : [])
269
374
  ],
270
375
  };
271
376
  } catch (error) {
@@ -1,7 +1,6 @@
1
- import { getToken, saveToken } from './token-storage.js';
1
+ import { saveToken } from './token-storage.js';
2
2
  import { config } from './config.js';
3
3
  import { parseJWT } from './jwt-utils.js';
4
- import { getOrgScopedToken } from './clerk-api.js';
5
4
 
6
5
  /**
7
6
  * Check if token has expired
@@ -23,6 +22,7 @@ export function isTokenExpired(tokenData) {
23
22
 
24
23
  /**
25
24
  * Refresh access token using refresh token
25
+ * Simple refresh - Clerk preserves org context if it was set
26
26
  * @param {Object} tokenData - Current token data
27
27
  * @returns {Promise<Object>} - New token data
28
28
  */
@@ -74,36 +74,22 @@ export async function refreshAccessToken(tokenData) {
74
74
 
75
75
  // Parse refreshed JWT
76
76
  const refreshedToken = newTokenData.id_token || newTokenData.access_token;
77
- let finalIdToken = newTokenData.id_token;
78
- let decoded = parseJWT(refreshedToken);
77
+ const decoded = parseJWT(refreshedToken);
79
78
 
80
- // If we have org context, get org-scoped token to ensure JWT has org claims
81
- if (tokenData.selectedOrg?.id && decoded?.sid) {
82
- try {
83
- const orgToken = await getOrgScopedToken(
84
- decoded.sid,
85
- tokenData.selectedOrg.id,
86
- refreshedToken
87
- );
88
- finalIdToken = orgToken.jwt;
89
- decoded = parseJWT(orgToken.jwt);
90
- } catch (error) {
91
- // Log warning but continue with base token
92
- // The subsequent API call may fail, prompting re-login
93
- console.error(`⚠️ Could not refresh org-scoped token: ${error.message}`);
94
- }
95
- }
96
-
97
- // Build updated token object, preserving selectedOrg
79
+ // Build updated token object
80
+ // Note: Clerk preserves org context through refresh automatically
81
+ // We also preserve selectedOrgId/selectedOrgName from login state
98
82
  const updatedToken = {
99
83
  accessToken: newTokenData.access_token,
100
- idToken: finalIdToken,
84
+ idToken: newTokenData.id_token,
101
85
  tokenType: newTokenData.token_type || 'Bearer',
102
86
  expiresIn: newTokenData.expires_in,
103
87
  receivedAt: Date.now(),
104
88
  decoded: decoded,
105
- selectedOrg: tokenData.selectedOrg, // Preserve organization context
106
89
  refresh_token: newTokenData.refresh_token || tokenData.refresh_token,
90
+ sessionId: tokenData.sessionId, // Preserve sessionId through refresh
91
+ selectedOrgId: tokenData.selectedOrgId, // Preserve org selection through refresh
92
+ selectedOrgName: tokenData.selectedOrgName, // Preserve org name through refresh
107
93
  };
108
94
 
109
95
  // Save to storage
@@ -14,12 +14,6 @@ export function saveToken(tokenData) {
14
14
  // Ensure directory permissions are correct (in case it was created with wrong permissions)
15
15
  fs.chmodSync(TOKEN_DIR, 0o700);
16
16
 
17
- // Preserve existing organization context if not explicitly overwritten
18
- const existingData = getToken();
19
- if (existingData && existingData.selectedOrg && !tokenData.selectedOrg) {
20
- tokenData.selectedOrg = existingData.selectedOrg;
21
- }
22
-
23
17
  // Write token file with owner-only read/write permissions (0600)
24
18
  fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
25
19
  // Explicitly set file permissions (writeFileSync mode option doesn't always work with extended attributes)
@@ -46,31 +40,43 @@ export function clearToken() {
46
40
  }
47
41
 
48
42
  /**
49
- * Update the selected organization context
50
- * @param {Object|null} orgData - Organization data or null for personal context
43
+ * Get organization context from JWT claims
44
+ * JWT is the single source of truth for org context
45
+ * @returns {Object|null} Organization context or null if personal context
51
46
  */
52
- export function updateSelectedOrg(orgData) {
53
- const tokenData = getToken();
54
- if (!tokenData) {
55
- throw new Error('No token found. Please login first.');
47
+ export function getOrgContext() {
48
+ const token = getToken();
49
+
50
+ // Try nested 'o' object first (original OAuth token format)
51
+ if (token?.decoded?.o?.o_id) {
52
+ return {
53
+ id: token.decoded.o.o_id,
54
+ name: token.decoded.o.o_name,
55
+ slug: token.decoded.o.o_slug,
56
+ role: token.decoded.o.o_role
57
+ };
56
58
  }
57
59
 
58
- // Ensure directory permissions are correct
59
- if (fs.existsSync(TOKEN_DIR)) {
60
- fs.chmodSync(TOKEN_DIR, 0o700);
60
+ // Fall back to flat org_* claims (org-scoped token from Clerk)
61
+ if (token?.decoded?.org_id) {
62
+ return {
63
+ id: token.decoded.org_id,
64
+ name: token.decoded.org_name,
65
+ slug: token.decoded.org_slug,
66
+ role: token.decoded.org_role
67
+ };
61
68
  }
62
69
 
63
- tokenData.selectedOrg = orgData;
64
- // Write with owner-only read/write permissions (0600)
65
- fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
66
- fs.chmodSync(TOKEN_FILE, 0o600);
67
- }
70
+ // Fall back to selectedOrgId from login state parameter
71
+ // This is used when JWT doesn't have org claims (Clerk setActive limitation)
72
+ if (token?.selectedOrgId) {
73
+ return {
74
+ id: token.selectedOrgId,
75
+ name: token.selectedOrgName || null,
76
+ slug: null,
77
+ role: null
78
+ };
79
+ }
68
80
 
69
- /**
70
- * Get the currently selected organization context
71
- * @returns {Object|null} Organization data or null if personal context
72
- */
73
- export function getSelectedOrg() {
74
- const tokenData = getToken();
75
- return tokenData?.selectedOrg || null;
81
+ return null;
76
82
  }
@@ -1,11 +1,9 @@
1
- import { getToken, saveToken } from './token-storage.js';
1
+ import { getToken } from './token-storage.js';
2
2
  import { isTokenExpired, refreshAccessToken } from './token-refresh.js';
3
- import { getOrgScopedToken } from './clerk-api.js';
4
- import { parseJWT } from './jwt-utils.js';
5
- import { config } from './config.js';
6
3
 
7
4
  /**
8
5
  * Load token and refresh if expired
6
+ * JWT is the single source of truth - no recovery logic needed
9
7
  * @param {boolean} requireAuth - Whether to throw if not authenticated (default true)
10
8
  * @returns {Promise<Object|null>} - Token data or null
11
9
  * @throws {Error} - If not authenticated and requireAuth is true, or if refresh fails
@@ -46,86 +44,5 @@ export async function loadAndRefreshToken(requireAuth = true) {
46
44
  }
47
45
  }
48
46
 
49
- // Check if token has org context but lacks org claims in JWT
50
- // This can happen if getOrgScopedToken() failed during login
51
- if (tokenData.selectedOrg?.id && !tokenData.decoded?.o) {
52
- console.error('⚠️ Token missing org claims, attempting to fix...');
53
-
54
- // Check for sid in decoded token or preserved originalSid from login
55
- let sessionId = tokenData.decoded?.sid || tokenData.originalSid;
56
- let currentToken = tokenData.idToken || tokenData.accessToken;
57
-
58
- // If no sid, try to refresh first to get a new base token with sid
59
- if (!sessionId && tokenData.refresh_token) {
60
- console.error(' Refreshing to obtain session ID...');
61
- try {
62
- const tokenParams = {
63
- client_id: config.clerkClientId,
64
- refresh_token: tokenData.refresh_token,
65
- grant_type: 'refresh_token',
66
- };
67
-
68
- if (config.clerkClientSecret) {
69
- tokenParams.client_secret = config.clerkClientSecret;
70
- }
71
-
72
- const response = await fetch(`https://${config.clerkDomain}/oauth/token`, {
73
- method: 'POST',
74
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
75
- body: new URLSearchParams(tokenParams),
76
- });
77
-
78
- if (response.ok) {
79
- const newTokenData = await response.json();
80
- const refreshedToken = newTokenData.id_token || newTokenData.access_token;
81
- const refreshedDecoded = parseJWT(refreshedToken);
82
-
83
- sessionId = refreshedDecoded?.sid;
84
- currentToken = refreshedToken;
85
-
86
- // Update tokenData with refreshed values
87
- tokenData.accessToken = newTokenData.access_token;
88
- tokenData.idToken = newTokenData.id_token;
89
- tokenData.refresh_token = newTokenData.refresh_token || tokenData.refresh_token;
90
- tokenData.decoded = refreshedDecoded;
91
-
92
- if (sessionId) {
93
- console.error(' ✓ Obtained session ID');
94
- }
95
- }
96
- } catch (error) {
97
- console.error(` Could not refresh token: ${error.message}`);
98
- }
99
- }
100
-
101
- // Now try to get org-scoped token if we have sessionId
102
- if (sessionId) {
103
- try {
104
- const orgToken = await getOrgScopedToken(
105
- sessionId,
106
- tokenData.selectedOrg.id,
107
- currentToken
108
- );
109
-
110
- const decoded = parseJWT(orgToken.jwt);
111
- const updatedToken = {
112
- ...tokenData,
113
- idToken: orgToken.jwt,
114
- decoded: decoded,
115
- };
116
-
117
- saveToken(updatedToken);
118
- console.error('✓ Token updated with org claims');
119
- return updatedToken;
120
- } catch (error) {
121
- console.error(`⚠️ Could not get org-scoped token: ${error.message}`);
122
- // Continue with existing token - API calls may fail
123
- }
124
- } else {
125
- console.error('⚠️ Could not obtain session ID for org-scoped token');
126
- console.error(' Please run: um login');
127
- }
128
- }
129
-
130
47
  return tokenData;
131
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unifiedmemory/cli",
3
- "version": "1.3.12",
3
+ "version": "1.3.14",
4
4
  "description": "UnifiedMemory CLI - AI code assistant integration",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -221,7 +221,7 @@ describe('mcp-proxy', () => {
221
221
 
222
222
  const authContext = {
223
223
  decoded: { sub: 'user_123' },
224
- selectedOrg: { id: 'org_456' },
224
+ orgContext: { id: 'org_456' },
225
225
  };
226
226
 
227
227
  const projectContext = {
@@ -245,7 +245,7 @@ describe('mcp-proxy', () => {
245
245
  expect(capturedBody.params.arguments.headers['X-Org-Id']).toBe('org_456');
246
246
  });
247
247
 
248
- it('should fallback to user_id for org when no selectedOrg', async () => {
248
+ it('should not inject org when no orgContext (personal context)', async () => {
249
249
  let capturedBody = null;
250
250
 
251
251
  server.use(
@@ -263,7 +263,7 @@ describe('mcp-proxy', () => {
263
263
 
264
264
  const authContext = {
265
265
  decoded: { sub: 'user_123' },
266
- // No selectedOrg
266
+ // No orgContext - personal context
267
267
  };
268
268
 
269
269
  await callRemoteMCPTool(
@@ -274,9 +274,12 @@ describe('mcp-proxy', () => {
274
274
  null
275
275
  );
276
276
 
277
- // Org should fallback to user_id
278
- expect(capturedBody.params.arguments.pathParams.org).toBe('user_123');
279
- expect(capturedBody.params.arguments.headers['X-Org-Id']).toBe('user_123');
277
+ // No org injection for personal context - gateway handles this
278
+ expect(capturedBody.params.arguments.pathParams.org).toBeUndefined();
279
+ expect(capturedBody.params.arguments.headers['X-Org-Id']).toBeUndefined();
280
+ // User should still be injected
281
+ expect(capturedBody.params.arguments.pathParams.user).toBe('user_123');
282
+ expect(capturedBody.params.arguments.headers['X-User-Id']).toBe('user_123');
280
283
  });
281
284
 
282
285
  it('should return tool execution result', async () => {
@@ -44,17 +44,9 @@ vi.mock('../../lib/jwt-utils.js', () => ({
44
44
  }),
45
45
  }));
46
46
 
47
- vi.mock('../../lib/clerk-api.js', () => ({
48
- getOrgScopedToken: vi.fn().mockResolvedValue({
49
- jwt: 'org_scoped_jwt',
50
- object: 'token',
51
- }),
52
- }));
53
-
54
47
  // Import after mocking
55
48
  import { isTokenExpired, refreshAccessToken } from '../../lib/token-refresh.js';
56
49
  import { saveToken } from '../../lib/token-storage.js';
57
- import { getOrgScopedToken } from '../../lib/clerk-api.js';
58
50
 
59
51
  describe('isTokenExpired', () => {
60
52
  it('returns true for null tokenData', () => {
@@ -181,7 +173,7 @@ describe('refreshAccessToken', () => {
181
173
  );
182
174
  });
183
175
 
184
- it('preserves selectedOrg in refreshed token', async () => {
176
+ it('preserves selectedOrgId and selectedOrgName in refreshed token', async () => {
185
177
  global.fetch = vi.fn().mockResolvedValue({
186
178
  ok: true,
187
179
  json: () => Promise.resolve({
@@ -193,60 +185,42 @@ describe('refreshAccessToken', () => {
193
185
  }),
194
186
  });
195
187
 
196
- const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
197
- await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
188
+ await refreshAccessToken({
189
+ refresh_token: 'rt_123',
190
+ selectedOrgId: 'org_456',
191
+ selectedOrgName: 'Test Org',
192
+ });
198
193
 
199
194
  expect(saveToken).toHaveBeenCalledWith(
200
195
  expect.objectContaining({
201
- selectedOrg: selectedOrg,
196
+ selectedOrgId: 'org_456',
197
+ selectedOrgName: 'Test Org',
202
198
  })
203
199
  );
204
200
  });
205
201
 
206
- it('gets org-scoped token when selectedOrg exists and refreshed token has sid', async () => {
202
+ it('preserves sessionId through refresh', async () => {
207
203
  global.fetch = vi.fn().mockResolvedValue({
208
204
  ok: true,
209
205
  json: () => Promise.resolve({
210
206
  access_token: 'new_access_token',
211
- id_token: 'new_id_token_with_sid',
207
+ id_token: 'new_id_token',
212
208
  refresh_token: 'new_refresh_token',
213
209
  token_type: 'Bearer',
214
210
  expires_in: 3600,
215
211
  }),
216
212
  });
217
213
 
218
- const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
219
- await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
220
-
221
- expect(getOrgScopedToken).toHaveBeenCalledWith(
222
- 'sess_new_123',
223
- 'org_456',
224
- 'new_id_token_with_sid'
225
- );
226
- });
227
-
228
- it('continues with base token when getOrgScopedToken fails', async () => {
229
- global.fetch = vi.fn().mockResolvedValue({
230
- ok: true,
231
- json: () => Promise.resolve({
232
- access_token: 'new_access_token',
233
- id_token: 'new_id_token_with_sid',
234
- refresh_token: 'new_refresh_token',
235
- token_type: 'Bearer',
236
- expires_in: 3600,
237
- }),
214
+ await refreshAccessToken({
215
+ refresh_token: 'rt_123',
216
+ sessionId: 'sess_123',
238
217
  });
239
218
 
240
- // Make getOrgScopedToken fail
241
- vi.mocked(getOrgScopedToken).mockRejectedValueOnce(new Error('API error'));
242
-
243
- const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
244
-
245
- // Should not throw - continues with base token
246
- const result = await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
247
-
248
- expect(result).toBeDefined();
249
- expect(result.idToken).toBe('new_id_token_with_sid'); // Falls back to base token
219
+ expect(saveToken).toHaveBeenCalledWith(
220
+ expect.objectContaining({
221
+ sessionId: 'sess_123',
222
+ })
223
+ );
250
224
  });
251
225
 
252
226
  it('throws error when OAuth refresh fails with 400', async () => {
@@ -132,7 +132,6 @@ describe('token-storage module behavior', () => {
132
132
  expect(typeof ts.saveToken).toBe('function');
133
133
  expect(typeof ts.getToken).toBe('function');
134
134
  expect(typeof ts.clearToken).toBe('function');
135
- expect(typeof ts.updateSelectedOrg).toBe('function');
136
- expect(typeof ts.getSelectedOrg).toBe('function');
135
+ expect(typeof ts.getOrgContext).toBe('function');
137
136
  });
138
137
  });