Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 32 additions & 23 deletions packages/v1-ready/clio/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { OAuth2Requester, get } from '@friggframework/core';
import crypto from 'crypto';
import {
ClioRegion,
ClioClientCredentials,
ClioOAuth2Options,
ClioResponse,
ClioUser,
Expand Down Expand Up @@ -30,17 +31,14 @@ import {
ListMattersParams,
} from './types';

const REGION_BASE_URLS: Record<ClioRegion, string> = {
us: 'https://app.clio.com/api/v4',
eu: 'https://eu.app.clio.com/api/v4',
ca: 'https://ca.app.clio.com/api/v4',
au: 'https://au.app.clio.com/api/v4',
// Each region is a separate Clio instance with its own OAuth server and apps
const REGION_HOSTS: Record<ClioRegion, string> = {
us: 'https://app.clio.com',
eu: 'https://eu.app.clio.com',
ca: 'https://ca.app.clio.com',
au: 'https://au.app.clio.com',
};

// OAuth always uses US domain regardless of region
// Only the API base URL changes per region
const OAUTH_BASE_URL = 'https://app.clio.com';

const API_VERSION = '4.0.12';

const CLIO_HEADERS = {
Expand All @@ -53,7 +51,9 @@ const CLIO_HEADERS_JSON = {
};

export class Api extends OAuth2Requester {
region: ClioRegion;
region!: ClioRegion;
defaultCredentials: { client_id: string; client_secret: string };
regionalCredentials: Partial<Record<ClioRegion, ClioClientCredentials>>;
URLs: {
whoAmI: string;
contacts: string;
Expand All @@ -77,15 +77,13 @@ export class Api extends OAuth2Requester {
constructor(params: ClioOAuth2Options = {}) {
super(params);

// Set region for API calls (defaults to 'us')
this.region = get(params, 'region', 'us') as ClioRegion;
this.baseUrl = REGION_BASE_URLS[this.region];

// OAuth URLs - ALWAYS use US domain regardless of region
this.authorizationUri = encodeURI(
`${OAUTH_BASE_URL}/oauth/authorize?client_id=${this.client_id}&redirect_uri=${this.redirect_uri}&response_type=code`,
);
this.tokenUri = `${OAUTH_BASE_URL}/oauth/token`;
this.defaultCredentials = {
client_id: this.client_id,
client_secret: this.client_secret,
};
this.regionalCredentials = get(params, 'regional_credentials', {});
// Entities saved without a region (null or '') are US accounts
this.setRegion((get(params, 'region', null) || 'us') as ClioRegion);

this.URLs = {
whoAmI: '/users/who_am_i.json',
Expand Down Expand Up @@ -115,18 +113,29 @@ export class Api extends OAuth2Requester {
}

/**
* Sets the region and updates the API base URL.
* Note: OAuth URLs remain fixed to US domain (app.clio.com).
* Points the API, OAuth endpoints and client credentials at a region.
* A token issued in one region is not valid in another.
*/
setRegion(region: ClioRegion): void {
if (!REGION_BASE_URLS[region]) {
const host = REGION_HOSTS[region];
if (!host) {
throw new Error(
`Invalid Clio region: ${region}. Must be one of: us, eu, ca, au`,
);
}

const regional = this.regionalCredentials[region];
this.client_id =
regional?.client_id || this.defaultCredentials.client_id;
this.client_secret =
regional?.client_secret || this.defaultCredentials.client_secret;

this.region = region;
this.baseUrl = REGION_BASE_URLS[region];
this.baseUrl = `${host}/api/v4`;
this.authorizationUri = encodeURI(
`${host}/oauth/authorize?client_id=${this.client_id}&redirect_uri=${this.redirect_uri}&response_type=code`,
);
this.tokenUri = `${host}/oauth/token`;
}

// ==================== User ====================
Expand Down
25 changes: 24 additions & 1 deletion packages/v1-ready/clio/src/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@ const Definition: FriggModuleAuthDefinition = {
moduleName: config.name,
modelName: 'Clio',
requiredAuthMethods: {
getToken: async (api: Api, params: { code: string }) => {
getToken: async (
api: Api,
params: { code: string; region?: ClioRegion },
) => {
const code = get(params, 'code');

if (!code) {
throw new Error('Authorization code is required');
}

// The code must be exchanged in the region that issued it
const region = get(params, 'region', null);
if (region) {
api.setRegion(region);
}

try {
return await api.getTokenFromCode(code);
} catch (error: any) {
Expand Down Expand Up @@ -141,6 +150,20 @@ const Definition: FriggModuleAuthDefinition = {
client_id: process.env.CLIO_CLIENT_ID,
client_secret: process.env.CLIO_CLIENT_SECRET,
redirect_uri: `${process.env.REDIRECT_URI}/clio`,
regional_credentials: {
eu: {
client_id: process.env.CLIO_EU_CLIENT_ID,
client_secret: process.env.CLIO_EU_CLIENT_SECRET,
},
ca: {
client_id: process.env.CLIO_CA_CLIENT_ID,
client_secret: process.env.CLIO_CA_CLIENT_SECRET,
},
au: {
client_id: process.env.CLIO_AU_CLIENT_ID,
client_secret: process.env.CLIO_AU_CLIENT_SECRET,
},
},
// Note: Clio scopes are configured directly in the Developer Portal, not via OAuth scope.
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/v1-ready/clio/src/frigg-core.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,6 @@ declare module '@friggframework/core' {
uiSchema?: any;
};
refreshEntityOptions?: (api: any, options: any) => Promise<void>;
env: Record<string, string | undefined>;
env: Record<string, unknown>;
}
}
10 changes: 10 additions & 0 deletions packages/v1-ready/clio/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@

export type ClioRegion = 'us' | 'eu' | 'ca' | 'au';

export interface ClioClientCredentials {
client_id?: string;
client_secret?: string;
}

export interface ClioOAuth2Options {
access_token?: string;
refresh_token?: string;
region?: ClioRegion;
client_id?: string;
client_secret?: string;
redirect_uri?: string;
// Per-region developer app credentials; unset regions use client_id/secret
regional_credentials?: Partial<Record<ClioRegion, ClioClientCredentials>>;
}

export interface ClioTokenResponse {
Expand Down
114 changes: 114 additions & 0 deletions packages/v1-ready/clio/test/regions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from 'vitest';
import { Api } from '../src/api';
import { Definition } from '../src/definition';

const credentials = {
client_id: 'us-id',
client_secret: 'us-secret',
redirect_uri: 'https://example.com/redirect/clio',
regional_credentials: {
ca: { client_id: 'ca-id', client_secret: 'ca-secret' },
},
};

function captureTokenPost(api: Api) {
return vi
.spyOn(api, '_post')
.mockResolvedValue({ access_token: 'a', refresh_token: 'r' });
}

describe('Clio regions', () => {
it('defaults to the US host and credentials', () => {
const api = new Api(credentials);

expect(api.region).toBe('us');
expect(api.baseUrl).toBe('https://app.clio.com/api/v4');
expect(api.tokenUri).toBe('https://app.clio.com/oauth/token');
expect(api.client_id).toBe('us-id');
});

it.each([null, ''])('treats a saved region of %j as US', (region) => {
const api = new Api({ ...credentials, region: region as any });

expect(api.region).toBe('us');
expect(api.tokenUri).toBe('https://app.clio.com/oauth/token');
});

it('uses the regional host and credentials for a persisted region', () => {
const api = new Api({ ...credentials, region: 'ca' });

expect(api.baseUrl).toBe('https://ca.app.clio.com/api/v4');
expect(api.tokenUri).toBe('https://ca.app.clio.com/oauth/token');
expect(api.authorizationUri).toContain(
'https://ca.app.clio.com/oauth/authorize?client_id=ca-id',
);
expect(api.client_id).toBe('ca-id');
expect(api.client_secret).toBe('ca-secret');
});

it('falls back to the default credentials when a region has none', () => {
const api = new Api({ ...credentials, region: 'eu' });

expect(api.tokenUri).toBe('https://eu.app.clio.com/oauth/token');
expect(api.client_id).toBe('us-id');
});

it('restores the default credentials when switching back to US', () => {
const api = new Api({ ...credentials, region: 'ca' });
api.setRegion('us');

expect(api.tokenUri).toBe('https://app.clio.com/oauth/token');
expect(api.client_id).toBe('us-id');
expect(api.client_secret).toBe('us-secret');
});

it('rejects an unknown region', () => {
const api = new Api(credentials);

expect(() => api.setRegion('xx' as any)).toThrow(
'Invalid Clio region: xx',
);
});

it('exchanges the code in the region sent with it', async () => {
const api = new Api(credentials);
const post = captureTokenPost(api);

await Definition.requiredAuthMethods.getToken(api, {
code: 'the-code',
region: 'ca',
});

const [options] = post.mock.calls[0] as any[];
expect(options.url).toBe('https://ca.app.clio.com/oauth/token');
expect(options.body.get('client_id')).toBe('ca-id');
expect(options.body.get('client_secret')).toBe('ca-secret');
expect(options.body.get('code')).toBe('the-code');
expect(api.region).toBe('ca');
});

it('keeps the US exchange when no region is sent', async () => {
const api = new Api(credentials);
const post = captureTokenPost(api);

await Definition.requiredAuthMethods.getToken(api, {
code: 'the-code',
});

const [options] = post.mock.calls[0] as any[];
expect(options.url).toBe('https://app.clio.com/oauth/token');
expect(options.body.get('client_id')).toBe('us-id');
});

it('refreshes against the persisted region', async () => {
const api = new Api({ ...credentials, region: 'ca' });
const post = captureTokenPost(api);

await (api as any).refreshAccessToken({ refresh_token: 'r' });

const [options] = post.mock.calls[0] as any[];
expect(options.url).toBe('https://ca.app.clio.com/oauth/token');
expect(options.body.get('client_id')).toBe('ca-id');
expect(options.body.get('refresh_token')).toBe('r');
});
});
Loading