@topolo/sdk 0.7.0 → 0.8.1

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.
@@ -1,378 +0,0 @@
1
- import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import { homedir, platform } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
- import { TopoloOAuth } from '../oauth.js';
6
- import type { TopoloCredential } from '../auth.js';
7
-
8
- /**
9
- * Node-only credential store shared by the `topolo` CLI and `@topolo/mcp`.
10
- *
11
- * Both need to read (and refresh) the credential that `topolo auth login`
12
- * writes to `$XDG_CONFIG_HOME/topolo/config.json`. This lives in a Node-only
13
- * subpath (`@topolo/sdk/node`) so the isomorphic SDK core never imports
14
- * `node:fs` — importing this module in a browser/worker bundle is a mistake.
15
- *
16
- * The CLI re-exports every symbol here from `packages/topolo-cli/src/config.ts`
17
- * so existing call sites are unchanged; the MCP imports it directly to fall
18
- * back to the on-disk credential when no `TOPOLO_*` env credential is set.
19
- */
20
-
21
- export const TOPOLO_ENVIRONMENTS = ['production', 'staging', 'development', 'local'] as const;
22
- export type TopoloEnvironment = (typeof TOPOLO_ENVIRONMENTS)[number];
23
-
24
- export interface TopoloConfig {
25
- credential?: TopoloCredential;
26
- /** OAuth client_id used for the most recent login — needed to refresh the access token. */
27
- oauthClientId?: string;
28
- /** Per-service URL overrides — written when user runs `topolo config set-url`. */
29
- serviceUrls?: Record<string, string>;
30
- /** Optional agent label propagated as `X-Topolo-Agent`. */
31
- agentName?: string;
32
- /** Environment-scoped profiles. Legacy top-level fields are treated as production. */
33
- profiles?: Partial<Record<TopoloEnvironment, TopoloEnvironmentProfile>>;
34
- }
35
-
36
- export interface TopoloEnvironmentProfile {
37
- credential?: TopoloCredential;
38
- oauthClientId?: string;
39
- serviceUrls?: Record<string, string>;
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';
69
- }
70
-
71
- /**
72
- * Default service URLs per environment. `local` has no baked defaults — a
73
- * local dev loop points services at ad-hoc ports, so it is driven entirely by
74
- * `TOPOLO_LOCAL_SERVICE_URL_*` / `TOPOLO_SERVICE_URL_*` overrides.
75
- */
76
- export const ENVIRONMENT_DEFAULT_SERVICE_URLS: Record<
77
- TopoloEnvironment,
78
- Record<string, string>
79
- > = {
80
- production: {
81
- developers: 'https://developers.topolo.app',
82
- },
83
- staging: {
84
- auth: 'https://auth.stg.topolo.us',
85
- developers: 'https://developers.stg.topolo.us',
86
- seed: 'https://seed.stg.topolo.us',
87
- },
88
- development: {
89
- auth: 'https://auth.topolo.dev',
90
- developers: 'https://developers.topolo.dev',
91
- },
92
- local: {},
93
- };
94
-
95
- export function configPath(): string {
96
- const override = process.env.TOPOLO_CONFIG_PATH;
97
- if (override) return override;
98
- const base = process.env.XDG_CONFIG_HOME
99
- || (platform() === 'win32'
100
- ? join(homedir(), 'AppData', 'Roaming')
101
- : join(homedir(), '.config'));
102
- return join(base, 'topolo', 'config.json');
103
- }
104
-
105
- export async function readConfig(): Promise<TopoloConfig> {
106
- const path = configPath();
107
- if (!existsSync(path)) return {};
108
- try {
109
- const raw = await readFile(path, 'utf8');
110
- return JSON.parse(raw) as TopoloConfig;
111
- } catch {
112
- return {};
113
- }
114
- }
115
-
116
- export async function writeConfig(config: TopoloConfig): Promise<void> {
117
- const path = configPath();
118
- await mkdir(dirname(path), { recursive: true });
119
- await writeFile(path, JSON.stringify(config, null, 2) + '\n', 'utf8');
120
- if (platform() !== 'win32') {
121
- try {
122
- await chmod(path, 0o600);
123
- } catch {
124
- /* non-fatal */
125
- }
126
- }
127
- }
128
-
129
- export function normalizeEnvironment(value: string | undefined): TopoloEnvironment {
130
- const normalized = (value ?? 'production').trim().toLowerCase();
131
- if (normalized === '' || normalized === 'production' || normalized === 'prod') {
132
- return 'production';
133
- }
134
- if (normalized === 'staging' || normalized === 'stage' || normalized === 'stg') {
135
- return 'staging';
136
- }
137
- if (normalized === 'development' || normalized === 'dev') {
138
- return 'development';
139
- }
140
- if (normalized === 'local' || normalized === 'localhost') {
141
- return 'local';
142
- }
143
- throw new Error(
144
- `Unknown Topolo environment "${value}". Expected production, staging, development, or local.`,
145
- );
146
- }
147
-
148
- export function selectedEnvironment(): TopoloEnvironment {
149
- return normalizeEnvironment(process.env.TOPOLO_ENV);
150
- }
151
-
152
- export function envVarPrefix(environment: TopoloEnvironment): string {
153
- if (environment === 'production') return 'TOPOLO_PRODUCTION';
154
- if (environment === 'staging') return 'TOPOLO_STAGING';
155
- if (environment === 'development') return 'TOPOLO_DEVELOPMENT';
156
- return 'TOPOLO_LOCAL';
157
- }
158
-
159
- export function profileForEnvironment(
160
- config: TopoloConfig,
161
- environment: TopoloEnvironment = selectedEnvironment(),
162
- ): TopoloEnvironmentProfile {
163
- const profile = config.profiles?.[environment] ?? {};
164
- if (environment === 'production') {
165
- return {
166
- credential: config.credential,
167
- oauthClientId: config.oauthClientId,
168
- ...profile,
169
- serviceUrls: {
170
- ...(config.serviceUrls ?? {}),
171
- ...(profile.serviceUrls ?? {}),
172
- },
173
- agentName: profile.agentName ?? config.agentName,
174
- };
175
- }
176
- return profile;
177
- }
178
-
179
- export function writeProfileConfig(
180
- config: TopoloConfig,
181
- environment: TopoloEnvironment,
182
- profile: TopoloEnvironmentProfile,
183
- ): TopoloConfig {
184
- const profiles = { ...(config.profiles ?? {}) };
185
- profiles[environment] = {
186
- ...(profiles[environment] ?? {}),
187
- ...profile,
188
- };
189
- return { ...config, profiles };
190
- }
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
-
231
- export function clearProfileCredential(
232
- config: TopoloConfig,
233
- environment: TopoloEnvironment,
234
- ): TopoloConfig {
235
- const next = { ...config };
236
- if (environment === 'production') {
237
- delete next.credential;
238
- delete next.oauthClientId;
239
- }
240
- const profiles = { ...(config.profiles ?? {}) };
241
- const profile = { ...(profiles[environment] ?? {}) };
242
- delete profile.credential;
243
- delete profile.oauthClientId;
244
- profiles[environment] = profile;
245
- return { ...next, profiles };
246
- }
247
-
248
- export function credentialSourceFromConfig(
249
- config: TopoloConfig,
250
- environment: TopoloEnvironment = selectedEnvironment(),
251
- ): string {
252
- const prefix = envVarPrefix(environment);
253
- if (process.env[`${prefix}_API_KEY`]) return `env:${prefix}_API_KEY`;
254
- if (process.env[`${prefix}_ACCESS_TOKEN`]) return `env:${prefix}_ACCESS_TOKEN`;
255
- if (process.env.TOPOLO_API_KEY) return 'env:TOPOLO_API_KEY';
256
- if (process.env.TOPOLO_ACCESS_TOKEN) return 'env:TOPOLO_ACCESS_TOKEN';
257
- return profileForEnvironment(config, environment).credential ? 'config-file' : 'none';
258
- }
259
-
260
- /**
261
- * Resolve credentials with env-var precedence over on-disk config.
262
- * Env vars are what agents set in their environment; config is what
263
- * interactive users get after running `topolo auth login`.
264
- */
265
- export async function resolveCredential(
266
- environment: TopoloEnvironment = selectedEnvironment(),
267
- ): Promise<TopoloCredential | null> {
268
- const prefix = envVarPrefix(environment);
269
- const apiKey = process.env[`${prefix}_API_KEY`] ?? process.env.TOPOLO_API_KEY;
270
- if (apiKey) return { kind: 'api_key', apiKey };
271
-
272
- const accessToken = process.env[`${prefix}_ACCESS_TOKEN`] ?? process.env.TOPOLO_ACCESS_TOKEN;
273
- if (accessToken) return { kind: 'access_token', accessToken };
274
-
275
- const config = await readConfig();
276
- return profileForEnvironment(config, environment).credential ?? null;
277
- }
278
-
279
- /**
280
- * If the stored access_token is expired (or within the skew window) and a
281
- * refresh_token + oauthClientId are present, exchange the refresh_token for a
282
- * new pair and persist. On any failure returns the existing (stale) credential
283
- * so the next API call fails naturally with auth_error — we don't nuke the
284
- * user's config on a transient network blip.
285
- */
286
- const REFRESH_SKEW_SECONDS = 60;
287
-
288
- export async function refreshCredentialIfNeeded(
289
- environment: TopoloEnvironment = selectedEnvironment(),
290
- ): Promise<TopoloCredential | null> {
291
- const prefix = envVarPrefix(environment);
292
- if (
293
- process.env[`${prefix}_API_KEY`]
294
- || process.env[`${prefix}_ACCESS_TOKEN`]
295
- || process.env.TOPOLO_API_KEY
296
- || process.env.TOPOLO_ACCESS_TOKEN
297
- ) {
298
- return resolveCredential(environment);
299
- }
300
-
301
- const config = await readConfig();
302
- const profile = profileForEnvironment(config, environment);
303
- const cred = profile.credential;
304
- if (!cred || cred.kind !== 'access_token') return cred ?? null;
305
- if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
306
-
307
- const now = Math.floor(Date.now() / 1000);
308
- if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
309
-
310
- try {
311
- const oauth = new TopoloOAuth({
312
- serviceUrls: resolveServiceUrls(profile.serviceUrls, environment) as Partial<
313
- Record<'auth' | 'crm', string>
314
- >,
315
- });
316
- const tokens = await oauth.refreshToken({
317
- clientId: profile.oauthClientId,
318
- refreshToken: cred.refreshToken,
319
- });
320
- const refreshed: TopoloCredential = {
321
- kind: 'access_token',
322
- accessToken: tokens.access_token,
323
- ...(tokens.refresh_token
324
- ? { refreshToken: tokens.refresh_token }
325
- : { refreshToken: cred.refreshToken }),
326
- expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
327
- };
328
- await writeConfig(
329
- environment === 'production' && !config.profiles?.production
330
- ? { ...config, credential: refreshed }
331
- : writeProfileConfig(config, environment, { credential: refreshed }),
332
- );
333
- return refreshed;
334
- } catch {
335
- return cred;
336
- }
337
- }
338
-
339
- export function resolveAgentName(configAgentName?: string): string | undefined {
340
- return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
341
- }
342
-
343
- export function resolveServiceUrls(
344
- configUrls?: Record<string, string>,
345
- environment: TopoloEnvironment = selectedEnvironment(),
346
- ): Record<string, string> | undefined {
347
- const merged: Record<string, string> = {
348
- ...ENVIRONMENT_DEFAULT_SERVICE_URLS[environment],
349
- ...(configUrls ?? {}),
350
- };
351
-
352
- // Env wins. Pull env-specific and global service URL vars that might be set.
353
- const prefix = `${envVarPrefix(environment)}_SERVICE_URL_`;
354
- for (const [k, v] of Object.entries(process.env)) {
355
- if (k.startsWith(prefix) && v) {
356
- merged[k.slice(prefix.length).toLowerCase()] = v;
357
- }
358
- }
359
- for (const [k, v] of Object.entries(process.env)) {
360
- if (k.startsWith('TOPOLO_SERVICE_URL_') && v) {
361
- merged[k.slice('TOPOLO_SERVICE_URL_'.length).toLowerCase()] = v;
362
- }
363
- }
364
- return Object.keys(merged).length > 0 ? merged : undefined;
365
- }
366
-
367
- export function resolvedProfileConfig(
368
- config: TopoloConfig,
369
- environment: TopoloEnvironment = selectedEnvironment(),
370
- ): TopoloEnvironmentProfile {
371
- const profile = profileForEnvironment(config, environment);
372
- return {
373
- credential: profile.credential,
374
- oauthClientId: profile.oauthClientId,
375
- agentName: profile.agentName,
376
- serviceUrls: resolveServiceUrls(profile.serviceUrls, environment),
377
- };
378
- }
package/src/oauth.test.ts DELETED
@@ -1,216 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { TopoloOAuth } from './oauth.js';
3
- import { TopoloAuthError, TopoloHttpError } from './errors.js';
4
-
5
- function stubFetch(next: { status: number; body: unknown }) {
6
- const calls: Array<{ url: string; init: RequestInit }> = [];
7
- const impl = vi.fn(async (url: string, init: RequestInit) => {
8
- calls.push({ url, init });
9
- return new Response(JSON.stringify(next.body ?? {}), {
10
- status: next.status,
11
- headers: { 'Content-Type': 'application/json' },
12
- });
13
- });
14
- return { impl: impl as unknown as typeof fetch, calls };
15
- }
16
-
17
- describe('TopoloOAuth.pollDeviceToken', () => {
18
- it('maps authorization_pending response into TopoloAuthError(code=authorization_pending)', async () => {
19
- const { impl } = stubFetch({
20
- status: 400,
21
- body: { error: 'authorization_pending', error_description: 'Pending user approval' },
22
- });
23
- const oauth = new TopoloOAuth({ fetch: impl });
24
- await expect(
25
- oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' }),
26
- ).rejects.toMatchObject({
27
- name: 'TopoloAuthError',
28
- code: 'authorization_pending',
29
- });
30
- });
31
-
32
- it('maps slow_down into TopoloAuthError(code=slow_down)', async () => {
33
- const { impl } = stubFetch({
34
- status: 400,
35
- body: { error: 'slow_down', error_description: 'Back off' },
36
- });
37
- const oauth = new TopoloOAuth({ fetch: impl });
38
- await expect(
39
- oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' }),
40
- ).rejects.toMatchObject({ code: 'slow_down' });
41
- });
42
-
43
- it('returns TokenResponse on 200', async () => {
44
- const { impl } = stubFetch({
45
- status: 200,
46
- body: { access_token: 'at', token_type: 'Bearer', expires_in: 3600, refresh_token: 'rt' },
47
- });
48
- const oauth = new TopoloOAuth({ fetch: impl });
49
- const res = await oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' });
50
- expect(res.access_token).toBe('at');
51
- expect(res.refresh_token).toBe('rt');
52
- });
53
-
54
- it('wraps unknown non-OAuth failures as TopoloHttpError', async () => {
55
- const { impl } = stubFetch({ status: 500, body: { unexpected: true } });
56
- const oauth = new TopoloOAuth({ fetch: impl });
57
- await expect(
58
- oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' }),
59
- ).rejects.toBeInstanceOf(TopoloHttpError);
60
- });
61
- });
62
-
63
- describe('TopoloOAuth.refreshToken', () => {
64
- it('POSTs grant_type=refresh_token with the client_id + refresh_token', async () => {
65
- const { impl, calls } = stubFetch({
66
- status: 200,
67
- body: { access_token: 'new', token_type: 'Bearer', expires_in: 3600, refresh_token: 'new_rt' },
68
- });
69
- const oauth = new TopoloOAuth({ fetch: impl });
70
- const res = await oauth.refreshToken({ clientId: 'doac_x', refreshToken: 'rt_old' });
71
- expect(res.access_token).toBe('new');
72
- const body = new URLSearchParams(calls[0]!.init.body as string);
73
- expect(body.get('grant_type')).toBe('refresh_token');
74
- expect(body.get('client_id')).toBe('doac_x');
75
- expect(body.get('refresh_token')).toBe('rt_old');
76
- });
77
-
78
- it('surfaces invalid_grant as TopoloAuthError', async () => {
79
- const { impl } = stubFetch({
80
- status: 400,
81
- body: { error: 'invalid_grant', error_description: 'Refresh token revoked' },
82
- });
83
- const oauth = new TopoloOAuth({ fetch: impl });
84
- await expect(
85
- oauth.refreshToken({ clientId: 'doac_x', refreshToken: 'rt_dead' }),
86
- ).rejects.toMatchObject({
87
- name: 'TopoloAuthError',
88
- code: 'invalid_grant',
89
- });
90
- });
91
- });
92
-
93
- describe('TopoloOAuth.requestDeviceCode', () => {
94
- it('joins array scopes with a space', async () => {
95
- const { impl, calls } = stubFetch({
96
- status: 200,
97
- body: {
98
- device_code: 'dc',
99
- user_code: 'ABCD-1234',
100
- verification_uri: 'https://ex/dev',
101
- expires_in: 600,
102
- interval: 5,
103
- },
104
- });
105
- const oauth = new TopoloOAuth({ fetch: impl });
106
- await oauth.requestDeviceCode({ clientId: 'doac_x', scope: ['read', 'write'] });
107
- const body = new URLSearchParams(calls[0]!.init.body as string);
108
- expect(body.get('scope')).toBe('read write');
109
- });
110
- });
111
-
112
- describe('TopoloOAuth.exchangeAuthorizationCode', () => {
113
- it('POSTs grant_type=authorization_code with PKCE verifier + secret', async () => {
114
- const { impl, calls } = stubFetch({
115
- status: 200,
116
- body: { access_token: 'at', token_type: 'Bearer', expires_in: 3600 },
117
- });
118
- const oauth = new TopoloOAuth({ fetch: impl });
119
- const res = await oauth.exchangeAuthorizationCode({
120
- clientId: 'doac_x',
121
- code: 'auth_code_1',
122
- redirectUri: 'https://app/cb',
123
- codeVerifier: 'verifier_1',
124
- clientSecret: 'secret_1',
125
- });
126
- expect(res.access_token).toBe('at');
127
- const body = new URLSearchParams(calls[0]!.init.body as string);
128
- expect(body.get('grant_type')).toBe('authorization_code');
129
- expect(body.get('code')).toBe('auth_code_1');
130
- expect(body.get('redirect_uri')).toBe('https://app/cb');
131
- expect(body.get('code_verifier')).toBe('verifier_1');
132
- expect(body.get('client_secret')).toBe('secret_1');
133
- });
134
-
135
- it('omits optional PKCE verifier + secret when not provided', async () => {
136
- const { impl, calls } = stubFetch({
137
- status: 200,
138
- body: { access_token: 'at', token_type: 'Bearer', expires_in: 3600 },
139
- });
140
- const oauth = new TopoloOAuth({ fetch: impl });
141
- await oauth.exchangeAuthorizationCode({
142
- clientId: 'doac_x',
143
- code: 'c',
144
- redirectUri: 'https://app/cb',
145
- });
146
- const body = new URLSearchParams(calls[0]!.init.body as string);
147
- expect(body.has('code_verifier')).toBe(false);
148
- expect(body.has('client_secret')).toBe(false);
149
- });
150
- });
151
-
152
- describe('TopoloOAuth.requestDeviceCode (single scope + omitted)', () => {
153
- function deviceBody() {
154
- return {
155
- device_code: 'dc',
156
- user_code: 'X',
157
- verification_uri: 'https://ex/dev',
158
- expires_in: 600,
159
- interval: 5,
160
- };
161
- }
162
-
163
- it('passes a string scope through unchanged', async () => {
164
- const { impl, calls } = stubFetch({ status: 200, body: deviceBody() });
165
- const oauth = new TopoloOAuth({ fetch: impl });
166
- await oauth.requestDeviceCode({ clientId: 'doac_x', scope: 'read' });
167
- expect(new URLSearchParams(calls[0]!.init.body as string).get('scope')).toBe('read');
168
- });
169
-
170
- it('omits scope when not provided', async () => {
171
- const { impl, calls } = stubFetch({ status: 200, body: deviceBody() });
172
- const oauth = new TopoloOAuth({ fetch: impl });
173
- await oauth.requestDeviceCode({ clientId: 'doac_x' });
174
- expect(new URLSearchParams(calls[0]!.init.body as string).has('scope')).toBe(false);
175
- });
176
- });
177
-
178
- describe('TopoloOAuth #postForm content negotiation', () => {
179
- it('parses a non-JSON (text/plain) successful body as a string', async () => {
180
- const impl = vi.fn(async () =>
181
- new Response('plain-ok', { status: 200, headers: { 'Content-Type': 'text/plain' } }),
182
- ) as unknown as typeof fetch;
183
- const oauth = new TopoloOAuth({ fetch: impl });
184
- const res = await oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' });
185
- expect(res as unknown).toBe('plain-ok');
186
- });
187
-
188
- it('wraps a non-OAuth text error body in TopoloHttpError', async () => {
189
- const impl = vi.fn(async () =>
190
- new Response('upstream exploded', {
191
- status: 502,
192
- headers: { 'Content-Type': 'text/plain' },
193
- }),
194
- ) as unknown as typeof fetch;
195
- const oauth = new TopoloOAuth({ fetch: impl });
196
- await expect(
197
- oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' }),
198
- ).rejects.toBeInstanceOf(TopoloHttpError);
199
- });
200
-
201
- it('normalizes a base URL that already ends with a slash', async () => {
202
- const { impl, calls } = stubFetch({
203
- status: 200,
204
- body: { access_token: 'at', token_type: 'Bearer', expires_in: 1 },
205
- });
206
- const oauth = new TopoloOAuth({
207
- fetch: impl,
208
- serviceUrls: { auth: 'https://auth.example/' },
209
- });
210
- await oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' });
211
- expect(calls[0]!.url).toBe('https://auth.example/api/developer-oauth/token');
212
- });
213
- });
214
-
215
- // Keep the TopoloAuthError import non-dead for type inference
216
- void TopoloAuthError;