mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
## 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.
370 lines
8.4 KiB
TypeScript
370 lines
8.4 KiB
TypeScript
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
|
|
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
|
import { type Manifest } from 'twenty-shared/application';
|
|
|
|
export class ApplicationApi {
|
|
constructor(private readonly client: AxiosInstance) {}
|
|
|
|
async syncMarketplaceCatalog(): Promise<ApiResponse<boolean>> {
|
|
try {
|
|
const query = `
|
|
mutation SyncMarketplaceCatalog {
|
|
syncMarketplaceCatalog
|
|
}
|
|
`;
|
|
|
|
const response = await this.client.post(
|
|
'/metadata',
|
|
{ query },
|
|
{
|
|
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.syncMarketplaceCatalog,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
async findApplicationRegistrationByUniversalIdentifier(
|
|
universalIdentifier: string,
|
|
): Promise<
|
|
ApiResponse<{
|
|
id: string;
|
|
universalIdentifier: string;
|
|
name: string;
|
|
oAuthClientId: string;
|
|
} | null>
|
|
> {
|
|
try {
|
|
const query = `
|
|
query FindApplicationRegistrationByUniversalIdentifier($universalIdentifier: String!) {
|
|
findApplicationRegistrationByUniversalIdentifier(universalIdentifier: $universalIdentifier) {
|
|
id
|
|
universalIdentifier
|
|
name
|
|
oAuthClientId
|
|
}
|
|
}
|
|
`;
|
|
|
|
const response = await this.client.post(
|
|
'/metadata',
|
|
{
|
|
query,
|
|
variables: { universalIdentifier },
|
|
},
|
|
{
|
|
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
|
|
.findApplicationRegistrationByUniversalIdentifier,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
async createApplicationRegistration(input: {
|
|
name: string;
|
|
universalIdentifier: string;
|
|
}): Promise<
|
|
ApiResponse<{
|
|
applicationRegistration: {
|
|
id: string;
|
|
universalIdentifier: string;
|
|
oAuthClientId: string;
|
|
};
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
}>
|
|
> {
|
|
try {
|
|
const mutation = `
|
|
mutation CreateApplicationRegistration($input: CreateApplicationRegistrationInput!) {
|
|
createApplicationRegistration(input: $input) {
|
|
applicationRegistration {
|
|
id
|
|
universalIdentifier
|
|
oAuthClientId
|
|
}
|
|
accessToken
|
|
refreshToken
|
|
}
|
|
}
|
|
`;
|
|
|
|
const response = await this.client.post(
|
|
'/metadata',
|
|
{
|
|
query: mutation,
|
|
variables: { input },
|
|
},
|
|
{
|
|
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.createApplicationRegistration,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
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;
|
|
}): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
|
|
try {
|
|
const mutation = `
|
|
mutation CreateDevelopmentApplication($universalIdentifier: String!, $name: String!) {
|
|
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
|
|
id
|
|
universalIdentifier
|
|
}
|
|
}
|
|
`;
|
|
|
|
const response = await this.client.post(
|
|
'/metadata',
|
|
{
|
|
query: mutation,
|
|
variables: input,
|
|
},
|
|
{
|
|
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.createDevelopmentApplication,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
async syncApplication(manifest: Manifest): Promise<ApiResponse> {
|
|
try {
|
|
const mutation = `
|
|
mutation SyncApplication($manifest: JSON!) {
|
|
syncApplication(manifest: $manifest) {
|
|
applicationUniversalIdentifier
|
|
actions
|
|
}
|
|
}
|
|
`;
|
|
|
|
const variables = { manifest };
|
|
|
|
const response: AxiosResponse = await this.client.post(
|
|
'/metadata',
|
|
{
|
|
query: mutation,
|
|
variables,
|
|
},
|
|
{
|
|
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.syncApplication,
|
|
message: `Successfully synced application: ${manifest.application.displayName}`,
|
|
};
|
|
} catch (error) {
|
|
if (axios.isAxiosError(error) && error.response) {
|
|
const graphqlErrors = error.response.data?.errors;
|
|
|
|
if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) {
|
|
return {
|
|
success: false,
|
|
error: graphqlErrors[0]?.message || error.message,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
error.response.data?.message ||
|
|
`HTTP ${error.response.status}: ${error.message}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : error,
|
|
};
|
|
}
|
|
}
|
|
|
|
async uninstallApplication(
|
|
universalIdentifier: string,
|
|
): Promise<ApiResponse> {
|
|
try {
|
|
const mutation = `
|
|
mutation UninstallApplication($universalIdentifier: String!) {
|
|
uninstallApplication(universalIdentifier: $universalIdentifier)
|
|
}
|
|
`;
|
|
|
|
const variables = { universalIdentifier };
|
|
|
|
const response: AxiosResponse = await this.client.post(
|
|
'/metadata',
|
|
{
|
|
query: mutation,
|
|
variables,
|
|
},
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: '*/*',
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.data.errors) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
response.data.errors[0]?.message || 'Failed to delete application',
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: response.data.data.uninstallApplication,
|
|
message: 'Successfully uninstalled application',
|
|
};
|
|
} catch (error) {
|
|
if (axios.isAxiosError(error) && error.response) {
|
|
return {
|
|
success: false,
|
|
error: error.response.data?.errors?.[0]?.message || error.message,
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|