@topolo/sdk 0.6.0 → 0.8.0

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.
@@ -0,0 +1,337 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { readConfig, refreshCredentialIfNeeded, writeConfig } from './credential-store.js';
6
+ import {
7
+ activateContext,
8
+ clearProfileCredential,
9
+ configPath,
10
+ contextKey,
11
+ credentialSourceFromConfig,
12
+ envVarPrefix,
13
+ normalizeEnvironment,
14
+ profileForEnvironment,
15
+ resolveAgentName,
16
+ resolveCredential,
17
+ resolvedProfileConfig,
18
+ resolveServiceUrls,
19
+ selectedEnvironment,
20
+ writeContextCredential,
21
+ writeProfileConfig,
22
+ } from './credential-store.js';
23
+
24
+ const originalEnv = { ...process.env };
25
+ const originalFetch = globalThis.fetch;
26
+ let tempDirectory: string;
27
+
28
+ beforeEach(async () => {
29
+ tempDirectory = await mkdtemp(join(tmpdir(), 'topolo-sdk-credential-'));
30
+ process.env = { ...originalEnv, TOPOLO_CONFIG_PATH: join(tempDirectory, 'config.json') };
31
+ delete process.env.TOPOLO_API_KEY;
32
+ delete process.env.TOPOLO_ACCESS_TOKEN;
33
+ delete process.env.TOPOLO_ENV;
34
+ delete process.env.TOPOLO_AGENT_NAME;
35
+ delete process.env.TOPOLO_PRODUCTION_API_KEY;
36
+ delete process.env.TOPOLO_PRODUCTION_ACCESS_TOKEN;
37
+ delete process.env.TOPOLO_STAGING_API_KEY;
38
+ delete process.env.TOPOLO_STAGING_ACCESS_TOKEN;
39
+ for (const key of Object.keys(process.env)) {
40
+ if (key.startsWith('TOPOLO_SERVICE_URL_') || key.startsWith('TOPOLO_STAGING_SERVICE_URL_')) {
41
+ delete process.env[key];
42
+ }
43
+ }
44
+ });
45
+
46
+ describe('credential configuration primitives', () => {
47
+ it('normalizes environment aliases and rejects unknown values', () => {
48
+ expect(normalizeEnvironment(undefined)).toBe('production');
49
+ expect(normalizeEnvironment(' ')).toBe('production');
50
+ expect(normalizeEnvironment('PROD')).toBe('production');
51
+ expect(normalizeEnvironment('stage')).toBe('staging');
52
+ expect(normalizeEnvironment('stg')).toBe('staging');
53
+ expect(normalizeEnvironment('dev')).toBe('development');
54
+ expect(normalizeEnvironment('localhost')).toBe('local');
55
+ expect(() => normalizeEnvironment('preview')).toThrow(/Unknown Topolo environment/);
56
+
57
+ process.env.TOPOLO_ENV = 'staging';
58
+ expect(selectedEnvironment()).toBe('staging');
59
+ expect(envVarPrefix('production')).toBe('TOPOLO_PRODUCTION');
60
+ expect(envVarPrefix('staging')).toBe('TOPOLO_STAGING');
61
+ expect(envVarPrefix('development')).toBe('TOPOLO_DEVELOPMENT');
62
+ expect(envVarPrefix('local')).toBe('TOPOLO_LOCAL');
63
+ });
64
+
65
+ it('uses the explicit config path and safely reads missing or malformed config', async () => {
66
+ expect(configPath()).toBe(join(tempDirectory, 'config.json'));
67
+ expect(await readConfig()).toEqual({});
68
+ await import('node:fs/promises').then(({ writeFile }) => writeFile(configPath(), '{bad json'));
69
+ expect(await readConfig()).toEqual({});
70
+ });
71
+
72
+ it('merges legacy production fields with profiles and writes environment profiles', () => {
73
+ const config = {
74
+ credential: { kind: 'api_key' as const, apiKey: 'legacy' },
75
+ oauthClientId: 'legacy-client',
76
+ serviceUrls: { auth: 'https://legacy-auth', developers: 'https://legacy-dev' },
77
+ agentName: 'legacy-agent',
78
+ profiles: {
79
+ production: {
80
+ serviceUrls: { auth: 'https://profile-auth' },
81
+ agentName: 'profile-agent',
82
+ },
83
+ staging: { agentName: 'staging-agent' },
84
+ },
85
+ };
86
+ expect(profileForEnvironment(config, 'production')).toMatchObject({
87
+ credential: config.credential,
88
+ oauthClientId: 'legacy-client',
89
+ serviceUrls: { auth: 'https://profile-auth', developers: 'https://legacy-dev' },
90
+ agentName: 'profile-agent',
91
+ });
92
+ expect(profileForEnvironment(config, 'staging')).toEqual({ agentName: 'staging-agent' });
93
+ expect(writeProfileConfig(config, 'staging', { oauthClientId: 'new-client' }).profiles?.staging)
94
+ .toEqual({ agentName: 'staging-agent', oauthClientId: 'new-client' });
95
+ });
96
+
97
+ it('stores, activates, and clears personal and organization contexts', () => {
98
+ expect(contextKey('personal')).toBe('personal');
99
+ expect(contextKey('organization', 'org_1')).toBe('org:org_1');
100
+ expect(contextKey('organization')).toBe('personal');
101
+
102
+ const organization = {
103
+ contextType: 'organization' as const,
104
+ organizationId: 'org_1',
105
+ oauthClientId: 'context-client',
106
+ credential: { kind: 'access_token' as const, accessToken: 'org-token' },
107
+ };
108
+ const stored = writeContextCredential({}, 'staging', organization);
109
+ expect(stored.profiles?.staging).toMatchObject({
110
+ activeContext: 'org:org_1',
111
+ credential: organization.credential,
112
+ oauthClientId: 'context-client',
113
+ });
114
+
115
+ const inactive = writeContextCredential(stored, 'staging', {
116
+ contextType: 'personal',
117
+ credential: { kind: 'access_token', accessToken: 'personal-token' },
118
+ }, false);
119
+ expect(inactive.profiles?.staging?.activeContext).toBe('org:org_1');
120
+ const activated = activateContext(inactive, 'staging', 'personal');
121
+ expect(activated.profiles?.staging?.credential).toMatchObject({ accessToken: 'personal-token' });
122
+ expect(activateContext(activated, 'staging', 'missing')).toBe(activated);
123
+
124
+ const production = clearProfileCredential({
125
+ credential: { kind: 'api_key', apiKey: 'legacy' },
126
+ oauthClientId: 'client',
127
+ profiles: { production: { credential: organization.credential, oauthClientId: 'profile' } },
128
+ }, 'production');
129
+ expect(production.credential).toBeUndefined();
130
+ expect(production.oauthClientId).toBeUndefined();
131
+ expect(production.profiles?.production?.credential).toBeUndefined();
132
+ expect(clearProfileCredential(stored, 'staging').profiles?.staging?.credential).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ describe('credential and service resolution', () => {
137
+ it('reports credential source precedence and resolves environment credentials', async () => {
138
+ const config = { credential: { kind: 'api_key' as const, apiKey: 'stored' } };
139
+ expect(credentialSourceFromConfig(config, 'production')).toBe('config-file');
140
+ process.env.TOPOLO_ACCESS_TOKEN = 'global-access';
141
+ expect(credentialSourceFromConfig(config, 'production')).toBe('env:TOPOLO_ACCESS_TOKEN');
142
+ expect(await resolveCredential('production')).toEqual({ kind: 'access_token', accessToken: 'global-access' });
143
+ process.env.TOPOLO_API_KEY = 'global-key';
144
+ expect(credentialSourceFromConfig(config, 'production')).toBe('env:TOPOLO_API_KEY');
145
+ expect(await resolveCredential('production')).toEqual({ kind: 'api_key', apiKey: 'global-key' });
146
+ process.env.TOPOLO_PRODUCTION_ACCESS_TOKEN = 'scoped-access';
147
+ expect(credentialSourceFromConfig(config, 'production')).toBe('env:TOPOLO_PRODUCTION_ACCESS_TOKEN');
148
+ process.env.TOPOLO_PRODUCTION_API_KEY = 'scoped-key';
149
+ expect(credentialSourceFromConfig(config, 'production')).toBe('env:TOPOLO_PRODUCTION_API_KEY');
150
+ expect(await resolveCredential('production')).toEqual({ kind: 'api_key', apiKey: 'scoped-key' });
151
+ });
152
+
153
+ it('falls back to stored credentials or none', async () => {
154
+ expect(credentialSourceFromConfig({}, 'staging')).toBe('none');
155
+ expect(await resolveCredential('staging')).toBeNull();
156
+ await writeConfig({ profiles: { staging: { credential: { kind: 'api_key', apiKey: 'stored' } } } });
157
+ expect(await resolveCredential('staging')).toEqual({ kind: 'api_key', apiKey: 'stored' });
158
+ });
159
+
160
+ it('merges defaults, config, scoped env, and global env in precedence order', () => {
161
+ process.env.TOPOLO_STAGING_SERVICE_URL_AUTH = 'https://scoped-auth';
162
+ process.env.TOPOLO_SERVICE_URL_AUTH = 'https://global-auth';
163
+ process.env.TOPOLO_SERVICE_URL_CUSTOM = 'https://custom';
164
+ expect(resolveServiceUrls({ auth: 'https://config-auth' }, 'staging')).toMatchObject({
165
+ auth: 'https://global-auth',
166
+ developers: 'https://developers.stg.topolo.us',
167
+ seed: 'https://seed.stg.topolo.us',
168
+ custom: 'https://custom',
169
+ });
170
+ delete process.env.TOPOLO_STAGING_SERVICE_URL_AUTH;
171
+ delete process.env.TOPOLO_SERVICE_URL_AUTH;
172
+ delete process.env.TOPOLO_SERVICE_URL_CUSTOM;
173
+ expect(resolveServiceUrls(undefined, 'local')).toBeUndefined();
174
+ });
175
+
176
+ it('resolves agent and complete profile settings', () => {
177
+ expect(resolveAgentName('config-agent')).toBe('config-agent');
178
+ process.env.TOPOLO_AGENT_NAME = 'env-agent';
179
+ expect(resolveAgentName('config-agent')).toBe('env-agent');
180
+ const resolved = resolvedProfileConfig({
181
+ profiles: {
182
+ staging: {
183
+ oauthClientId: 'client',
184
+ credential: { kind: 'api_key', apiKey: 'stored' },
185
+ agentName: 'profile-agent',
186
+ serviceUrls: { auth: 'https://custom-auth' },
187
+ },
188
+ },
189
+ }, 'staging');
190
+ expect(resolved).toMatchObject({
191
+ oauthClientId: 'client',
192
+ credential: { kind: 'api_key', apiKey: 'stored' },
193
+ agentName: 'profile-agent',
194
+ serviceUrls: { auth: 'https://custom-auth' },
195
+ });
196
+ });
197
+ });
198
+
199
+ afterEach(async () => {
200
+ process.env = { ...originalEnv };
201
+ globalThis.fetch = originalFetch;
202
+ await rm(tempDirectory, { recursive: true, force: true });
203
+ });
204
+
205
+ describe('refreshCredentialIfNeeded force mode', () => {
206
+ it('refreshes a stored token after a 401 even when its expiry is still in the future', async () => {
207
+ await writeConfig({
208
+ credential: {
209
+ kind: 'access_token',
210
+ accessToken: 'rejected',
211
+ refreshToken: 'refresh',
212
+ expiresAt: Math.floor(Date.now() / 1000) + 10_000,
213
+ },
214
+ oauthClientId: 'topolo-cli',
215
+ });
216
+ globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
217
+ access_token: 'fresh',
218
+ token_type: 'Bearer',
219
+ expires_in: 3600,
220
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch;
221
+
222
+ const credential = await refreshCredentialIfNeeded('production', { force: true });
223
+
224
+ expect(credential).toMatchObject({ accessToken: 'fresh', refreshToken: 'refresh' });
225
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
226
+ });
227
+
228
+ it('rotates the active context credential as well as the profile pointer', async () => {
229
+ await writeConfig({
230
+ profiles: {
231
+ staging: {
232
+ activeContext: 'org:org_a',
233
+ oauthClientId: 'topolo-cli',
234
+ credential: {
235
+ kind: 'access_token',
236
+ accessToken: 'old-a',
237
+ refreshToken: 'refresh-a',
238
+ expiresAt: 1,
239
+ },
240
+ contexts: {
241
+ 'org:org_a': {
242
+ contextType: 'organization',
243
+ organizationId: 'org_a',
244
+ credential: {
245
+ kind: 'access_token',
246
+ accessToken: 'old-a',
247
+ refreshToken: 'refresh-a',
248
+ expiresAt: 1,
249
+ },
250
+ },
251
+ 'org:org_b': {
252
+ contextType: 'organization',
253
+ organizationId: 'org_b',
254
+ credential: {
255
+ kind: 'access_token',
256
+ accessToken: 'untouched-b',
257
+ refreshToken: 'refresh-b',
258
+ expiresAt: 2,
259
+ },
260
+ },
261
+ },
262
+ },
263
+ },
264
+ });
265
+ globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
266
+ access_token: 'fresh-a',
267
+ token_type: 'Bearer',
268
+ expires_in: 3600,
269
+ refresh_token: 'rotated-a',
270
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch;
271
+
272
+ await refreshCredentialIfNeeded('staging');
273
+
274
+ const profile = (await readConfig()).profiles?.staging;
275
+ expect(profile?.credential).toMatchObject({ accessToken: 'fresh-a', refreshToken: 'rotated-a' });
276
+ expect(profile?.contexts?.['org:org_a']?.credential).toMatchObject({
277
+ accessToken: 'fresh-a',
278
+ refreshToken: 'rotated-a',
279
+ });
280
+ expect(profile?.contexts?.['org:org_b']?.credential).toMatchObject({
281
+ accessToken: 'untouched-b',
282
+ refreshToken: 'refresh-b',
283
+ });
284
+ });
285
+
286
+ it('uses environment credentials and skips ineligible or unexpired stored credentials', async () => {
287
+ process.env.TOPOLO_STAGING_API_KEY = 'env-key';
288
+ expect(await refreshCredentialIfNeeded('staging')).toEqual({ kind: 'api_key', apiKey: 'env-key' });
289
+ delete process.env.TOPOLO_STAGING_API_KEY;
290
+ expect(await refreshCredentialIfNeeded('staging')).toBeNull();
291
+
292
+ await writeConfig({ profiles: { staging: { credential: { kind: 'api_key', apiKey: 'stored' } } } });
293
+ expect(await refreshCredentialIfNeeded('staging')).toEqual({ kind: 'api_key', apiKey: 'stored' });
294
+
295
+ const access = { kind: 'access_token' as const, accessToken: 'fresh', expiresAt: Math.floor(Date.now() / 1000) + 10_000 };
296
+ await writeConfig({ profiles: { staging: { credential: access, oauthClientId: 'client' } } });
297
+ expect(await refreshCredentialIfNeeded('staging')).toEqual(access);
298
+ });
299
+
300
+ it('returns the existing credential when refresh inputs are incomplete or exchange fails', async () => {
301
+ const withoutRefresh = { kind: 'access_token' as const, accessToken: 'token', expiresAt: 1 };
302
+ await writeConfig({ profiles: { staging: { credential: withoutRefresh, oauthClientId: 'client' } } });
303
+ expect(await refreshCredentialIfNeeded('staging')).toEqual(withoutRefresh);
304
+
305
+ const expired = { ...withoutRefresh, refreshToken: 'refresh' };
306
+ await writeConfig({ profiles: { staging: { credential: expired, oauthClientId: 'client' } } });
307
+ globalThis.fetch = vi.fn(async () => new Response('unavailable', { status: 503 })) as unknown as typeof fetch;
308
+ expect(await refreshCredentialIfNeeded('staging')).toEqual(expired);
309
+ });
310
+
311
+ it('persists refreshed credentials into an explicit production profile', async () => {
312
+ await writeConfig({
313
+ credential: { kind: 'api_key', apiKey: 'legacy' },
314
+ profiles: {
315
+ production: {
316
+ oauthClientId: 'client',
317
+ credential: { kind: 'access_token', accessToken: 'old', refreshToken: 'refresh', expiresAt: 1 },
318
+ },
319
+ },
320
+ });
321
+ globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
322
+ access_token: 'profile-fresh',
323
+ token_type: 'Bearer',
324
+ expires_in: 1200,
325
+ refresh_token: 'profile-refresh',
326
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as unknown as typeof fetch;
327
+
328
+ await refreshCredentialIfNeeded('production');
329
+
330
+ const config = await readConfig();
331
+ expect(config.credential).toEqual({ kind: 'api_key', apiKey: 'legacy' });
332
+ expect(config.profiles?.production?.credential).toMatchObject({
333
+ accessToken: 'profile-fresh',
334
+ refreshToken: 'profile-refresh',
335
+ });
336
+ });
337
+ });
@@ -38,6 +38,34 @@ export interface TopoloEnvironmentProfile {
38
38
  oauthClientId?: string;
39
39
  serviceUrls?: Record<string, string>;
40
40
  agentName?: string;
41
+ /**
42
+ * The context (`personal` or `org:<orgId>`) whose credential is currently
43
+ * active. `credential` above always mirrors `contexts[activeContext]`, so
44
+ * credential resolution + the MCP keep working unchanged while `contexts`
45
+ * lets the CLI hold and switch between multiple org/personal contexts.
46
+ */
47
+ activeContext?: string;
48
+ contexts?: Record<string, TopoloContextCredential>;
49
+ }
50
+
51
+ /** A signed-in context — personal, or a specific organization. */
52
+ export interface TopoloContextCredential {
53
+ contextType: 'personal' | 'organization';
54
+ organizationId?: string;
55
+ organizationSlug?: string;
56
+ organizationName?: string;
57
+ credential?: TopoloCredential;
58
+ oauthClientId?: string;
59
+ }
60
+
61
+ /** Stable key for a context within a profile's `contexts` map. */
62
+ export function contextKey(
63
+ contextType: 'personal' | 'organization',
64
+ organizationId?: string | null,
65
+ ): string {
66
+ return contextType === 'organization' && organizationId
67
+ ? `org:${organizationId}`
68
+ : 'personal';
41
69
  }
42
70
 
43
71
  /**
@@ -161,6 +189,45 @@ export function writeProfileConfig(
161
189
  return { ...config, profiles };
162
190
  }
163
191
 
192
+ /**
193
+ * Store a context's credential in the profile's `contexts` map. When
194
+ * `makeActive` (default), also point the profile's active credential at it so
195
+ * credential resolution + the MCP use this context.
196
+ */
197
+ export function writeContextCredential(
198
+ config: TopoloConfig,
199
+ environment: TopoloEnvironment,
200
+ ctx: TopoloContextCredential,
201
+ makeActive = true,
202
+ ): TopoloConfig {
203
+ const key = contextKey(ctx.contextType, ctx.organizationId);
204
+ const profile = profileForEnvironment(config, environment);
205
+ const contexts = { ...(profile.contexts ?? {}), [key]: ctx };
206
+ const next: TopoloEnvironmentProfile = { contexts };
207
+ if (makeActive) {
208
+ next.activeContext = key;
209
+ next.credential = ctx.credential;
210
+ next.oauthClientId = ctx.oauthClientId ?? profile.oauthClientId;
211
+ }
212
+ return writeProfileConfig(config, environment, next);
213
+ }
214
+
215
+ /** Point the profile's active credential at an already-stored context. */
216
+ export function activateContext(
217
+ config: TopoloConfig,
218
+ environment: TopoloEnvironment,
219
+ key: string,
220
+ ): TopoloConfig {
221
+ const profile = profileForEnvironment(config, environment);
222
+ const ctx = profile.contexts?.[key];
223
+ if (!ctx) return config;
224
+ return writeProfileConfig(config, environment, {
225
+ activeContext: key,
226
+ credential: ctx.credential,
227
+ oauthClientId: ctx.oauthClientId ?? profile.oauthClientId,
228
+ });
229
+ }
230
+
164
231
  export function clearProfileCredential(
165
232
  config: TopoloConfig,
166
233
  environment: TopoloEnvironment,
@@ -220,6 +287,7 @@ const REFRESH_SKEW_SECONDS = 60;
220
287
 
221
288
  export async function refreshCredentialIfNeeded(
222
289
  environment: TopoloEnvironment = selectedEnvironment(),
290
+ options: { force?: boolean } = {},
223
291
  ): Promise<TopoloCredential | null> {
224
292
  const prefix = envVarPrefix(environment);
225
293
  if (
@@ -238,7 +306,7 @@ export async function refreshCredentialIfNeeded(
238
306
  if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
239
307
 
240
308
  const now = Math.floor(Date.now() / 1000);
241
- if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
309
+ if (!options.force && cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
242
310
 
243
311
  try {
244
312
  const oauth = new TopoloOAuth({
@@ -258,11 +326,24 @@ export async function refreshCredentialIfNeeded(
258
326
  : { refreshToken: cred.refreshToken }),
259
327
  expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
260
328
  };
261
- await writeConfig(
262
- environment === 'production' && !config.profiles?.production
329
+ const activeContext = profile.activeContext;
330
+ const activeEntry = activeContext ? profile.contexts?.[activeContext] : undefined;
331
+ const updated = activeContext && activeEntry
332
+ ? writeProfileConfig(config, environment, {
333
+ credential: refreshed,
334
+ contexts: {
335
+ ...(profile.contexts ?? {}),
336
+ [activeContext]: {
337
+ ...activeEntry,
338
+ credential: refreshed,
339
+ oauthClientId: activeEntry.oauthClientId ?? profile.oauthClientId,
340
+ },
341
+ },
342
+ })
343
+ : environment === 'production' && !config.profiles?.production
263
344
  ? { ...config, credential: refreshed }
264
- : writeProfileConfig(config, environment, { credential: refreshed }),
265
- );
345
+ : writeProfileConfig(config, environment, { credential: refreshed });
346
+ await writeConfig(updated);
266
347
  return refreshed;
267
348
  } catch {
268
349
  return cred;