@topolo/sdk 0.7.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
+ });
@@ -287,6 +287,7 @@ const REFRESH_SKEW_SECONDS = 60;
287
287
 
288
288
  export async function refreshCredentialIfNeeded(
289
289
  environment: TopoloEnvironment = selectedEnvironment(),
290
+ options: { force?: boolean } = {},
290
291
  ): Promise<TopoloCredential | null> {
291
292
  const prefix = envVarPrefix(environment);
292
293
  if (
@@ -305,7 +306,7 @@ export async function refreshCredentialIfNeeded(
305
306
  if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
306
307
 
307
308
  const now = Math.floor(Date.now() / 1000);
308
- if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
309
+ if (!options.force && cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
309
310
 
310
311
  try {
311
312
  const oauth = new TopoloOAuth({
@@ -325,11 +326,24 @@ export async function refreshCredentialIfNeeded(
325
326
  : { refreshToken: cred.refreshToken }),
326
327
  expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
327
328
  };
328
- await writeConfig(
329
- 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
330
344
  ? { ...config, credential: refreshed }
331
- : writeProfileConfig(config, environment, { credential: refreshed }),
332
- );
345
+ : writeProfileConfig(config, environment, { credential: refreshed });
346
+ await writeConfig(updated);
333
347
  return refreshed;
334
348
  } catch {
335
349
  return cred;