mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
Use app's own OAuth credentials for CoreApiClient generation (#19563)
## Summary - **SDK (`dev` & `dev --once`)**: After app registration, the CLI now obtains an `APPLICATION_ACCESS` token via `client_credentials` grant using the app's own `clientId`/`clientSecret`, and uses that token for CoreApiClient schema introspection — instead of the user's `config.accessToken` which returns the full unscoped schema. - **Config**: `oauthClientSecret` is now persisted alongside `oauthClientId` in `~/.twenty/config.json` when creating a new app registration, so subsequent `dev`/`dev --once` runs can obtain fresh app tokens without re-registration. - **CI action**: `spawn-twenty-app-dev-test` now outputs a proper `API_KEY` JWT (signed with the seeded dev workspace secret) instead of the previous hardcoded `ACCESS` token — giving consumers a real API key rather than a user session token. ## Motivation When developing Twenty apps, `yarn twenty dev` was using the CLI user's OAuth token for GraphQL schema introspection during CoreApiClient generation. This token (type `ACCESS`) has no `applicationId` claim, so the server returns the **full workspace schema** — including all objects — rather than the scoped schema the app should see at runtime (filtered by `applicationId`). This caused a discrepancy: the generated CoreApiClient contained fields the app couldn't actually query at runtime with its `APPLICATION_ACCESS` token. By switching to `client_credentials` grant, the SDK now introspects with the same token type the app will use in production, ensuring the generated client accurately reflects the app's runtime capabilities.
This commit is contained in:
parent
f52d66b960
commit
c26c0b9d71
22 changed files with 263 additions and 142 deletions
|
|
@ -15,8 +15,8 @@ outputs:
|
|||
description: 'URL where the Twenty test server can be reached'
|
||||
value: http://localhost:2021
|
||||
api-key:
|
||||
description: 'API key for the Twenty test instance'
|
||||
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4
|
||||
description: 'API key (type: API_KEY) for the seeded Twenty dev workspace'
|
||||
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ export default defineConfig({
|
|||
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
|
||||
TWENTY_API_KEY:
|
||||
process.env.TWENTY_API_KEY ??
|
||||
// Tim Apple (admin) access token for twenty-app-dev
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ export default defineConfig({
|
|||
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
|
||||
TWENTY_API_KEY:
|
||||
process.env.TWENTY_API_KEY ??
|
||||
// Tim Apple (admin) access token for twenty-app-dev
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ export default defineConfig({
|
|||
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
|
||||
TWENTY_API_KEY:
|
||||
process.env.TWENTY_API_KEY ??
|
||||
// Tim Apple (admin) access token for twenty-app-dev
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
0
packages/twenty-apps/fixtures/invalid-app/yarn.lock
Normal file
0
packages/twenty-apps/fixtures/invalid-app/yarn.lock
Normal file
0
packages/twenty-apps/fixtures/minimal-app/yarn.lock
Normal file
0
packages/twenty-apps/fixtures/minimal-app/yarn.lock
Normal file
|
|
@ -20,7 +20,8 @@ const mockApiService = {
|
|||
id: 'mock-registration-id',
|
||||
oAuthClientId: 'mock-client-id',
|
||||
},
|
||||
clientSecret: 'mock-client-secret',
|
||||
accessToken: 'mock-app-access-token',
|
||||
refreshToken: 'mock-app-refresh-token',
|
||||
},
|
||||
}),
|
||||
createDevelopmentApplication: vi.fn().mockResolvedValue({
|
||||
|
|
@ -56,6 +57,12 @@ vi.mock('@/cli/utilities/file/file-uploader', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/cli/utilities/auth/resolve-app-access-token', () => ({
|
||||
ensureValidAppAccessTokenOrRefresh: vi
|
||||
.fn()
|
||||
.mockResolvedValue('mock-app-access-token'),
|
||||
}));
|
||||
|
||||
vi.mock('@/cli/utilities/client/client-service', () => ({
|
||||
ClientService: class {
|
||||
generateCoreClient = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export const registerRemoteCommands = (program: Command): void => {
|
|||
for (const remoteName of remotes) {
|
||||
const config = await configService.getConfigForRemote(remoteName);
|
||||
|
||||
const authMethod = config.accessToken
|
||||
const authMethod = config.twentyCLIAccessToken
|
||||
? 'oauth'
|
||||
: config.apiKey
|
||||
? 'api-key'
|
||||
|
|
@ -230,7 +230,7 @@ export const registerRemoteCommands = (program: Command): void => {
|
|||
const activeRemote = ConfigService.getActiveRemote();
|
||||
const config = await configService.getConfig();
|
||||
|
||||
const authMethod = config.accessToken
|
||||
const authMethod = config.twentyCLIAccessToken
|
||||
? 'oauth'
|
||||
: config.apiKey
|
||||
? 'api-key'
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
|
|||
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
|
||||
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
|
||||
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
|
||||
import { ensureValidAppAccessTokenOrRefresh } from '@/cli/utilities/auth/resolve-app-access-token';
|
||||
import { ClientService } from '@/cli/utilities/client/client-service';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events';
|
||||
|
|
@ -119,41 +120,34 @@ const innerAppDevOnce = async (
|
|||
onProgress?.('Registering application...');
|
||||
|
||||
const configService = new ConfigService();
|
||||
const registrationResult =
|
||||
await apiService.findApplicationRegistrationByUniversalIdentifier(
|
||||
manifest.application.universalIdentifier,
|
||||
);
|
||||
|
||||
if (!registrationResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message: `Failed to check app registration: ${serializeError(registrationResult.error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const appAccessToken =
|
||||
await ensureValidAppAccessTokenOrRefresh(configService);
|
||||
|
||||
if (!registrationResult.data) {
|
||||
const createRegistrationResult =
|
||||
await apiService.createApplicationRegistration({
|
||||
name: manifest.application.displayName,
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
});
|
||||
if (!appAccessToken) {
|
||||
const createResult = await apiService.createApplicationRegistration({
|
||||
name: manifest.application.displayName,
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
});
|
||||
|
||||
if (!createRegistrationResult.success) {
|
||||
if (!createResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.SYNC_FAILED,
|
||||
message: `Failed to create app registration: ${serializeError(createRegistrationResult.error)}`,
|
||||
message: `Failed to create app registration: ${serializeError(createResult.error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { applicationRegistration, accessToken, refreshToken } =
|
||||
createResult.data;
|
||||
|
||||
await configService.setConfig({
|
||||
oauthClientId:
|
||||
createRegistrationResult.data.applicationRegistration.oAuthClientId,
|
||||
appRegistrationId: applicationRegistration.id,
|
||||
appRegistrationClientId: applicationRegistration.oAuthClientId,
|
||||
appAccessToken: accessToken,
|
||||
appRefreshToken: refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -236,15 +230,18 @@ const innerAppDevOnce = async (
|
|||
};
|
||||
}
|
||||
|
||||
// Generate the CoreApiClient using an APPLICATION_ACCESS token so the
|
||||
// server returns the app-scoped schema (objects defined by this app).
|
||||
onProgress?.('Generating API client...');
|
||||
|
||||
try {
|
||||
const config = await configService.getConfig();
|
||||
const appAccessToken =
|
||||
await ensureValidAppAccessTokenOrRefresh(configService);
|
||||
const clientService = new ClientService();
|
||||
|
||||
await clientService.generateCoreClient({
|
||||
appPath,
|
||||
authToken: config.accessToken,
|
||||
appAccessToken,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -106,9 +106,9 @@ const innerAuthLoginOAuth = async (
|
|||
|
||||
await configService.setConfig({
|
||||
apiUrl,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
oauthClientId: clientId,
|
||||
twentyCLIAccessToken: accessToken,
|
||||
twentyCLIRefreshToken: refreshToken,
|
||||
twentyCLIRegistrationClientId: clientId,
|
||||
});
|
||||
|
||||
const apiService = new ApiService({
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ const innerAuthLogin = async (
|
|||
await configService.setConfig({
|
||||
apiUrl,
|
||||
apiKey,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
oauthClientId: undefined,
|
||||
twentyCLIAccessToken: undefined,
|
||||
twentyCLIRefreshToken: undefined,
|
||||
twentyCLIRegistrationClientId: undefined,
|
||||
});
|
||||
|
||||
const apiService = new ApiService();
|
||||
|
|
|
|||
|
|
@ -115,23 +115,26 @@ export class ApiClient {
|
|||
async refreshToken(): Promise<string | null> {
|
||||
const config = await this.configService.getConfig();
|
||||
|
||||
if (!config.refreshToken || !config.oauthClientId) {
|
||||
if (
|
||||
!config.twentyCLIRefreshToken ||
|
||||
!config.twentyCLIRegistrationClientId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await axios.post(`${config.apiUrl}/oauth/token`, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: config.refreshToken,
|
||||
client_id: config.oauthClientId,
|
||||
refresh_token: config.twentyCLIRefreshToken,
|
||||
client_id: config.twentyCLIRegistrationClientId,
|
||||
});
|
||||
|
||||
const { access_token: newAccessToken, refresh_token: newRefreshToken } =
|
||||
tokenResponse.data;
|
||||
|
||||
await this.configService.setConfig({
|
||||
accessToken: newAccessToken,
|
||||
...(newRefreshToken ? { refreshToken: newRefreshToken } : {}),
|
||||
twentyCLIAccessToken: newAccessToken,
|
||||
...(newRefreshToken ? { twentyCLIRefreshToken: newRefreshToken } : {}),
|
||||
});
|
||||
|
||||
return newAccessToken;
|
||||
|
|
@ -146,9 +149,9 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
const config = await this.configService.getConfig();
|
||||
const accessToken = config.accessToken;
|
||||
const cliToken = config.twentyCLIAccessToken;
|
||||
|
||||
if (accessToken && this.isTokenExpired(accessToken)) {
|
||||
if (cliToken && this.isTokenExpired(cliToken)) {
|
||||
const refreshed = await this.refreshToken();
|
||||
|
||||
if (refreshed) {
|
||||
|
|
@ -156,7 +159,7 @@ export class ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
return accessToken ?? config.apiKey;
|
||||
return cliToken ?? config.apiKey;
|
||||
}
|
||||
|
||||
private isTokenExpired(token: string): boolean {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@ export class ApiService {
|
|||
return this.applicationApi.createApplicationRegistration(...args);
|
||||
}
|
||||
|
||||
rotateApplicationRegistrationClientSecret(
|
||||
...args: Parameters<
|
||||
ApplicationApi['rotateApplicationRegistrationClientSecret']
|
||||
>
|
||||
) {
|
||||
return this.applicationApi.rotateApplicationRegistrationClientSecret(
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
createDevelopmentApplication(
|
||||
...args: Parameters<ApplicationApi['createDevelopmentApplication']>
|
||||
) {
|
||||
|
|
@ -70,12 +80,14 @@ export class ApiService {
|
|||
return this.applicationApi.syncMarketplaceCatalog();
|
||||
}
|
||||
|
||||
getSchema(options?: { authToken?: string }): Promise<ApiResponse<string>> {
|
||||
getSchema(options?: {
|
||||
appAccessToken?: string;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
return this.schemaApi.getSchema(options);
|
||||
}
|
||||
|
||||
getMetadataSchema(options?: {
|
||||
authToken?: string;
|
||||
appAccessToken?: string;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
return this.schemaApi.getMetadataSchema(options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ export class ApplicationApi {
|
|||
universalIdentifier: string;
|
||||
oAuthClientId: string;
|
||||
};
|
||||
clientSecret: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
|
|
@ -121,7 +122,8 @@ export class ApplicationApi {
|
|||
universalIdentifier
|
||||
oAuthClientId
|
||||
}
|
||||
clientSecret
|
||||
accessToken
|
||||
refreshToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -159,6 +161,51 @@ export class ApplicationApi {
|
|||
}
|
||||
}
|
||||
|
||||
async rotateApplicationRegistrationClientSecret(
|
||||
id: string,
|
||||
): Promise<ApiResponse<{ clientSecret: string }>> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation RotateApplicationRegistrationClientSecret($id: String!) {
|
||||
rotateApplicationRegistrationClientSecret(id: $id) {
|
||||
clientSecret
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query: mutation,
|
||||
variables: { id },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data.errors[0],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.rotateApplicationRegistrationClientSecret,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async createDevelopmentApplication(input: {
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -6,20 +6,20 @@ export class SchemaApi {
|
|||
constructor(private readonly client: AxiosInstance) {}
|
||||
|
||||
async getSchema(options?: {
|
||||
authToken?: string;
|
||||
appAccessToken?: string;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
return this.introspectEndpoint('/graphql', options);
|
||||
}
|
||||
|
||||
async getMetadataSchema(options?: {
|
||||
authToken?: string;
|
||||
appAccessToken?: string;
|
||||
}): Promise<ApiResponse<string>> {
|
||||
return this.introspectEndpoint('/metadata', options);
|
||||
}
|
||||
|
||||
private async introspectEndpoint(
|
||||
endpoint: string,
|
||||
options?: { authToken?: string },
|
||||
options?: { appAccessToken?: string },
|
||||
): Promise<ApiResponse<string>> {
|
||||
try {
|
||||
const introspectionQuery = getIntrospectionQuery();
|
||||
|
|
@ -29,8 +29,8 @@ export class SchemaApi {
|
|||
Accept: '*/*',
|
||||
};
|
||||
|
||||
if (options?.authToken) {
|
||||
headers.Authorization = `Bearer ${options.authToken}`;
|
||||
if (options?.appAccessToken) {
|
||||
headers.Authorization = `Bearer ${options.appAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await this.client.post(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { type ConfigService } from '@/cli/utilities/config/config-service';
|
||||
|
||||
const EXPIRATION_MARGIN_MS = 30_000;
|
||||
|
||||
const isTokenExpired = (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split('.')[1], 'base64').toString(),
|
||||
);
|
||||
|
||||
return payload.exp * 1_000 < Date.now() + EXPIRATION_MARGIN_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a valid appAccessToken from config, refreshing it first if expired.
|
||||
*/
|
||||
export const ensureValidAppAccessTokenOrRefresh = async (
|
||||
configService: ConfigService,
|
||||
): Promise<string | undefined> => {
|
||||
const config = await configService.getConfig();
|
||||
|
||||
if (!config.appAccessToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isTokenExpired(config.appAccessToken)) {
|
||||
return config.appAccessToken;
|
||||
}
|
||||
|
||||
if (!config.appRefreshToken || !config.appRegistrationClientId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: config.appRefreshToken,
|
||||
client_id: config.appRegistrationClientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
await configService.setConfig({
|
||||
appAccessToken: data.access_token,
|
||||
...(data.refresh_token ? { appRefreshToken: data.refresh_token } : {}),
|
||||
});
|
||||
|
||||
return data.access_token;
|
||||
};
|
||||
|
|
@ -39,15 +39,18 @@ export class FileUploadWatcher {
|
|||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const rootPaths = this.watchPaths.map((watchPath) =>
|
||||
join(this.appPath, watchPath),
|
||||
);
|
||||
const rootPaths = (
|
||||
await Promise.all(
|
||||
this.watchPaths.map(async (watchPath) => {
|
||||
const fullPath = join(this.appPath, watchPath);
|
||||
|
||||
for (const rootPath of rootPaths) {
|
||||
const exists = await pathExists(rootPath);
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
return (await pathExists(fullPath)) ? fullPath : null;
|
||||
}),
|
||||
)
|
||||
).filter((p): p is string => p !== null);
|
||||
|
||||
if (rootPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.watcher = chokidar.watch(rootPaths, {
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ export class ClientService {
|
|||
|
||||
async generateCoreClient({
|
||||
appPath,
|
||||
authToken,
|
||||
appAccessToken,
|
||||
}: {
|
||||
appPath: string;
|
||||
authToken?: string;
|
||||
appAccessToken?: string;
|
||||
}): Promise<void> {
|
||||
const coreSchemaResponse = await this.apiService.getSchema({ authToken });
|
||||
const coreSchemaResponse = await this.apiService.getSchema({
|
||||
appAccessToken,
|
||||
});
|
||||
|
||||
if (!coreSchemaResponse.success) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -8,9 +8,16 @@ import { getConfigPath } from '@/cli/utilities/config/get-config-path';
|
|||
export type RemoteConfig = {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
oauthClientId?: string;
|
||||
// CLI OAuth app credentials (from `yarn twenty remote add`)
|
||||
twentyCLIRegistrationId?: string;
|
||||
twentyCLIRegistrationClientId?: string;
|
||||
twentyCLIAccessToken?: string;
|
||||
twentyCLIRefreshToken?: string;
|
||||
// App registration credentials (from `createApplicationRegistration`)
|
||||
appRegistrationId?: string;
|
||||
appRegistrationClientId?: string;
|
||||
appAccessToken?: string;
|
||||
appRefreshToken?: string;
|
||||
};
|
||||
|
||||
type PersistedConfig = {
|
||||
|
|
@ -53,7 +60,7 @@ export class ConfigService {
|
|||
|
||||
// TODO: Remove after 2026-04-30 — migrates legacy config format
|
||||
// (profiles, top-level keys, applicationAccessToken/applicationRefreshToken)
|
||||
// to the current format (remotes, accessToken/refreshToken)
|
||||
// to the current format (remotes, twentyCLIAccessToken/twentyCLIRefreshToken)
|
||||
private async migrateConfigIfNeeded(
|
||||
raw: Record<string, unknown>,
|
||||
): Promise<PersistedConfig> {
|
||||
|
|
@ -78,11 +85,20 @@ export class ConfigService {
|
|||
): RemoteConfig => ({
|
||||
apiUrl: str(source.apiUrl) ?? '',
|
||||
apiKey: str(source.apiKey),
|
||||
accessToken:
|
||||
str(source.accessToken) ?? str(source.applicationAccessToken),
|
||||
refreshToken:
|
||||
str(source.refreshToken) ?? str(source.applicationRefreshToken),
|
||||
oauthClientId: str(source.oauthClientId),
|
||||
twentyCLIRegistrationClientId:
|
||||
str(source.twentyCLIRegistrationClientId) ?? str(source.oauthClientId),
|
||||
twentyCLIAccessToken:
|
||||
str(source.twentyCLIAccessToken) ??
|
||||
str(source.accessToken) ??
|
||||
str(source.applicationAccessToken),
|
||||
twentyCLIRefreshToken:
|
||||
str(source.twentyCLIRefreshToken) ??
|
||||
str(source.refreshToken) ??
|
||||
str(source.applicationRefreshToken),
|
||||
appRegistrationId: str(source.appRegistrationId),
|
||||
appRegistrationClientId: str(source.appRegistrationClientId),
|
||||
appAccessToken: str(source.appAccessToken),
|
||||
appRefreshToken: str(source.appRefreshToken),
|
||||
});
|
||||
|
||||
const profiles =
|
||||
|
|
@ -142,11 +158,17 @@ export class ConfigService {
|
|||
}
|
||||
|
||||
return {
|
||||
apiUrl: remoteConfig.apiUrl ?? defaultConfig.apiUrl,
|
||||
apiUrl: remoteConfig.apiUrl || defaultConfig.apiUrl,
|
||||
apiKey: remoteConfig.apiKey,
|
||||
accessToken: remoteConfig.accessToken,
|
||||
refreshToken: remoteConfig.refreshToken,
|
||||
oauthClientId: remoteConfig.oauthClientId,
|
||||
twentyCLIRegistrationId: remoteConfig.twentyCLIRegistrationId,
|
||||
twentyCLIRegistrationClientId:
|
||||
remoteConfig.twentyCLIRegistrationClientId,
|
||||
twentyCLIAccessToken: remoteConfig.twentyCLIAccessToken,
|
||||
twentyCLIRefreshToken: remoteConfig.twentyCLIRefreshToken,
|
||||
appRegistrationId: remoteConfig.appRegistrationId,
|
||||
appRegistrationClientId: remoteConfig.appRegistrationClientId,
|
||||
appAccessToken: remoteConfig.appAccessToken,
|
||||
appRefreshToken: remoteConfig.appRefreshToken,
|
||||
};
|
||||
} catch {
|
||||
return defaultConfig;
|
||||
|
|
@ -163,7 +185,7 @@ export class ConfigService {
|
|||
raw.remotes = {};
|
||||
}
|
||||
|
||||
const currentRemote = raw.remotes[remote] || { apiUrl: '' };
|
||||
const currentRemote = raw.remotes[remote] ?? this.getDefaultConfig();
|
||||
|
||||
raw.remotes[remote] = { ...currentRemote, ...config };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ensureValidAppAccessTokenOrRefresh } from '@/cli/utilities/auth/resolve-app-access-token';
|
||||
import { type ClientService } from '@/cli/utilities/client/client-service';
|
||||
import { type ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
|
|
@ -32,11 +33,13 @@ export class GenerateApiClientOrchestratorStep {
|
|||
this.notify();
|
||||
|
||||
try {
|
||||
const config = await this.configService.getConfig();
|
||||
const appAccessToken = await ensureValidAppAccessTokenOrRefresh(
|
||||
this.configService,
|
||||
);
|
||||
|
||||
await this.clientService.generateCoreClient({
|
||||
appPath: input.appPath,
|
||||
authToken: config.accessToken,
|
||||
appAccessToken,
|
||||
});
|
||||
|
||||
step.status = 'done';
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { ensureValidAppAccessTokenOrRefresh } from '@/cli/utilities/auth/resolve-app-access-token';
|
||||
import { type ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
export type RegisterAppOrchestratorStepOutput = {
|
||||
applicationRegistrationId: string | null;
|
||||
clientId: string | null;
|
||||
};
|
||||
|
||||
export class RegisterAppOrchestratorStep {
|
||||
private apiService: ApiService;
|
||||
private configService: ConfigService;
|
||||
|
|
@ -31,62 +27,42 @@ export class RegisterAppOrchestratorStep {
|
|||
this.notify = notify;
|
||||
}
|
||||
|
||||
async execute(input: {
|
||||
manifest: Manifest;
|
||||
}): Promise<RegisterAppOrchestratorStepOutput> {
|
||||
const universalIdentifier = input.manifest.application.universalIdentifier;
|
||||
async execute(input: { manifest: Manifest }): Promise<void> {
|
||||
const existingToken = await ensureValidAppAccessTokenOrRefresh(
|
||||
this.configService,
|
||||
);
|
||||
|
||||
const findResult =
|
||||
await this.apiService.findApplicationRegistrationByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (!findResult.success) {
|
||||
if (existingToken) {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message: 'Failed to check app registration',
|
||||
status: 'warning',
|
||||
},
|
||||
{ message: 'App registration found in config', status: 'info' },
|
||||
]);
|
||||
this.notify();
|
||||
|
||||
return { applicationRegistrationId: null, clientId: null };
|
||||
}
|
||||
|
||||
if (findResult.data) {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message: `App registration found: ${findResult.data.name}`,
|
||||
status: 'info',
|
||||
},
|
||||
]);
|
||||
this.notify();
|
||||
|
||||
return {
|
||||
applicationRegistrationId: findResult.data.id,
|
||||
clientId: findResult.data.oAuthClientId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const createResult = await this.apiService.createApplicationRegistration({
|
||||
name: input.manifest.application.displayName,
|
||||
universalIdentifier,
|
||||
universalIdentifier: input.manifest.application.universalIdentifier,
|
||||
});
|
||||
|
||||
if (!createResult.success || !createResult.data) {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message: 'Failed to create app registration',
|
||||
status: 'warning',
|
||||
},
|
||||
{ message: 'Failed to create app registration', status: 'warning' },
|
||||
]);
|
||||
this.notify();
|
||||
|
||||
return { applicationRegistrationId: null, clientId: null };
|
||||
return;
|
||||
}
|
||||
|
||||
const { applicationRegistration, accessToken, refreshToken } =
|
||||
createResult.data;
|
||||
|
||||
await this.configService.setConfig({
|
||||
oauthClientId: createResult.data.applicationRegistration.oAuthClientId,
|
||||
appRegistrationId: applicationRegistration.id,
|
||||
appRegistrationClientId: applicationRegistration.oAuthClientId,
|
||||
appAccessToken: accessToken,
|
||||
appRefreshToken: refreshToken,
|
||||
});
|
||||
|
||||
this.state.applyStepEvents([
|
||||
|
|
@ -95,24 +71,14 @@ export class RegisterAppOrchestratorStep {
|
|||
status: 'success',
|
||||
},
|
||||
{
|
||||
message: `Client ID: ${createResult.data.applicationRegistration.oAuthClientId}`,
|
||||
message: `Client ID: ${applicationRegistration.oAuthClientId}`,
|
||||
status: 'info',
|
||||
},
|
||||
{
|
||||
message: `Client Secret: ${createResult.data.clientSecret}`,
|
||||
status: 'warning',
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Credentials saved to config. The secret will not be shown again.',
|
||||
status: 'warning',
|
||||
message: 'Credentials saved to config.',
|
||||
status: 'info',
|
||||
},
|
||||
]);
|
||||
this.notify();
|
||||
|
||||
return {
|
||||
applicationRegistrationId: createResult.data.applicationRegistration.id,
|
||||
clientId: createResult.data.applicationRegistration.oAuthClientId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default defineConfig({
|
|||
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2021',
|
||||
TWENTY_API_KEY:
|
||||
process.env.TWENTY_API_KEY ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc',
|
||||
},
|
||||
setupFiles: ['src/cli/__tests__/constants/setupTest.ts'],
|
||||
globalSetup: undefined,
|
||||
|
|
|
|||
Loading…
Reference in a new issue