Provide applicatiion assets (#18973)

- improve backend
- improve frontend

<img width="1293" height="824" alt="image"
src="https://github.com/user-attachments/assets/7a4633f1-85cd-4126-b058-dbeae6ba2218"
/>
This commit is contained in:
martmull
2026-03-30 10:53:31 +02:00
committed by GitHub
parent 58189e1c05
commit fe1377f18b
74 changed files with 2367 additions and 2558 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.8.0-canary.5",
"version": "0.8.0-canary.6",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -211,14 +211,14 @@ export class CreateAppCommand {
if (!result.success) {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
'Authentication skipped. Run `yarn twenty remote add` manually.',
),
);
}
} catch {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
'Authentication skipped. Run `yarn twenty remote add` manually.',
),
);
}
@@ -236,7 +236,7 @@ export class CreateAppCommand {
if (!serverResult) {
console.log(
chalk.gray(
'- yarn twenty remote add --local # Authenticate with Twenty',
'- yarn twenty remote add # Authenticate with Twenty',
),
);
}
@@ -794,6 +794,7 @@ const createPackageJson = async ({
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
keywords: ['twenty-app'],
packageManager: 'yarn@4.9.2',
scripts,
devDependencies,
@@ -9,22 +9,11 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
"twenty": "twenty"
},
"dependencies": {
"twenty-sdk": "latest"
"twenty-sdk": "latest",
"twenty-client-sdk": "latest"
},
"devDependencies": {
"typescript": "^5.9.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "0.8.0-canary.5",
"version": "0.8.0-canary.6",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -38,9 +38,6 @@ type ApplicationRegistration {
id: UUID!
universalIdentifier: String!
name: String!
description: String
logoUrl: String
author: String
oAuthClientId: String!
oAuthRedirectUris: [String!]!
oAuthScopes: [String!]!
@@ -48,8 +45,6 @@ type ApplicationRegistration {
sourceType: ApplicationRegistrationSourceType!
sourcePackage: String
latestAvailableVersion: String
websiteUrl: String
termsUrl: String
isListed: Boolean!
isFeatured: Boolean!
createdAt: DateTime!
@@ -2421,91 +2416,30 @@ type File {
createdAt: DateTime!
}
type MarketplaceAppField {
name: String!
type: String!
label: String!
description: String
icon: String
objectUniversalIdentifier: String
universalIdentifier: String
}
type MarketplaceAppObject {
universalIdentifier: String!
nameSingular: String!
namePlural: String!
labelSingular: String!
labelPlural: String!
description: String
icon: String
fields: [MarketplaceAppField!]!
}
type MarketplaceAppLogicFunction {
name: String!
description: String
timeoutSeconds: Int
}
type MarketplaceAppFrontComponent {
name: String!
description: String
}
type MarketplaceAppRoleObjectPermission {
objectUniversalIdentifier: String!
canReadObjectRecords: Boolean
canUpdateObjectRecords: Boolean
canSoftDeleteObjectRecords: Boolean
canDestroyObjectRecords: Boolean
}
type MarketplaceAppRoleFieldPermission {
objectUniversalIdentifier: String!
fieldUniversalIdentifier: String!
canReadFieldValue: Boolean
canUpdateFieldValue: Boolean
}
type MarketplaceAppDefaultRole {
id: String!
label: String!
description: String
canReadAllObjectRecords: Boolean!
canUpdateAllObjectRecords: Boolean!
canSoftDeleteAllObjectRecords: Boolean!
canDestroyAllObjectRecords: Boolean!
canUpdateAllSettings: Boolean!
canAccessAllTools: Boolean!
objectPermissions: [MarketplaceAppRoleObjectPermission!]!
fieldPermissions: [MarketplaceAppRoleFieldPermission!]!
permissionFlags: [String!]!
}
type MarketplaceApp {
id: String!
name: String!
description: String!
icon: String!
version: String!
author: String!
category: String!
logo: String
screenshots: [String!]!
aboutDescription: String!
providers: [String!]!
websiteUrl: String
termsUrl: String
objects: [MarketplaceAppObject!]!
fields: [MarketplaceAppField!]!
logicFunctions: [MarketplaceAppLogicFunction!]!
frontComponents: [MarketplaceAppFrontComponent!]!
defaultRole: MarketplaceAppDefaultRole
sourcePackage: String
isFeatured: Boolean!
}
type MarketplaceAppDetail {
universalIdentifier: String!
id: String!
name: String!
sourceType: ApplicationRegistrationSourceType!
sourcePackage: String
latestAvailableVersion: String
isListed: Boolean!
isFeatured: Boolean!
manifest: JSON
}
type PublicDomain {
id: UUID!
domain: String!
@@ -3249,6 +3183,7 @@ type Query {
findApplicationRegistrationStats(id: String!): ApplicationRegistrationStats!
findApplicationRegistrationVariables(applicationRegistrationId: String!): [ApplicationRegistrationVariable!]!
applicationRegistrationTarballUrl(id: String!): String
getApplicationShareLink(id: String!): String!
currentUser: User!
currentWorkspace: Workspace!
getPublicWorkspaceDataByDomain(origin: String): PublicWorkspaceData!
@@ -3274,7 +3209,7 @@ type Query {
findManyPublicDomains: [PublicDomain!]!
getEmailingDomains: [EmailingDomain!]!
findManyMarketplaceApps: [MarketplaceApp!]!
findOneMarketplaceApp(universalIdentifier: String!): MarketplaceApp!
findMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail!
findManyApplications: [Application!]!
findOneApplication(id: UUID, universalIdentifier: UUID): Application!
}
@@ -3596,6 +3531,7 @@ type Mutation {
verifyEmailingDomain(id: String!): EmailingDomain!
createOneAppToken(input: CreateOneAppTokenInput!): AppToken!
installMarketplaceApp(universalIdentifier: String!, version: String): Boolean!
syncMarketplaceCatalog: Boolean!
installApplication(appRegistrationId: String!, version: String): Boolean!
runWorkspaceMigration(workspaceMigration: WorkspaceMigrationInput!): Boolean!
uninstallApplication(universalIdentifier: String!): Boolean!
@@ -4441,14 +4377,9 @@ input GetAuthorizationUrlForSSOInput {
input CreateApplicationRegistrationInput {
name: String!
description: String
logoUrl: String
author: String
universalIdentifier: String
oAuthRedirectUris: [String!]
oAuthScopes: [String!]
websiteUrl: String
termsUrl: String
}
input UpdateApplicationRegistrationInput {
@@ -4458,13 +4389,8 @@ input UpdateApplicationRegistrationInput {
input UpdateApplicationRegistrationPayload {
name: String
description: String
logoUrl: String
author: String
oAuthRedirectUris: [String!]
oAuthScopes: [String!]
websiteUrl: String
termsUrl: String
isListed: Boolean
}
@@ -42,9 +42,6 @@ export interface ApplicationRegistration {
id: Scalars['UUID']
universalIdentifier: Scalars['String']
name: Scalars['String']
description?: Scalars['String']
logoUrl?: Scalars['String']
author?: Scalars['String']
oAuthClientId: Scalars['String']
oAuthRedirectUris: Scalars['String'][]
oAuthScopes: Scalars['String'][]
@@ -52,8 +49,6 @@ export interface ApplicationRegistration {
sourceType: ApplicationRegistrationSourceType
sourcePackage?: Scalars['String']
latestAvailableVersion?: Scalars['String']
websiteUrl?: Scalars['String']
termsUrl?: Scalars['String']
isListed: Scalars['Boolean']
isFeatured: Scalars['Boolean']
createdAt: Scalars['DateTime']
@@ -2120,99 +2115,32 @@ export interface File {
__typename: 'File'
}
export interface MarketplaceAppField {
name: Scalars['String']
type: Scalars['String']
label: Scalars['String']
description?: Scalars['String']
icon?: Scalars['String']
objectUniversalIdentifier?: Scalars['String']
universalIdentifier?: Scalars['String']
__typename: 'MarketplaceAppField'
}
export interface MarketplaceAppObject {
universalIdentifier: Scalars['String']
nameSingular: Scalars['String']
namePlural: Scalars['String']
labelSingular: Scalars['String']
labelPlural: Scalars['String']
description?: Scalars['String']
icon?: Scalars['String']
fields: MarketplaceAppField[]
__typename: 'MarketplaceAppObject'
}
export interface MarketplaceAppLogicFunction {
name: Scalars['String']
description?: Scalars['String']
timeoutSeconds?: Scalars['Int']
__typename: 'MarketplaceAppLogicFunction'
}
export interface MarketplaceAppFrontComponent {
name: Scalars['String']
description?: Scalars['String']
__typename: 'MarketplaceAppFrontComponent'
}
export interface MarketplaceAppRoleObjectPermission {
objectUniversalIdentifier: Scalars['String']
canReadObjectRecords?: Scalars['Boolean']
canUpdateObjectRecords?: Scalars['Boolean']
canSoftDeleteObjectRecords?: Scalars['Boolean']
canDestroyObjectRecords?: Scalars['Boolean']
__typename: 'MarketplaceAppRoleObjectPermission'
}
export interface MarketplaceAppRoleFieldPermission {
objectUniversalIdentifier: Scalars['String']
fieldUniversalIdentifier: Scalars['String']
canReadFieldValue?: Scalars['Boolean']
canUpdateFieldValue?: Scalars['Boolean']
__typename: 'MarketplaceAppRoleFieldPermission'
}
export interface MarketplaceAppDefaultRole {
id: Scalars['String']
label: Scalars['String']
description?: Scalars['String']
canReadAllObjectRecords: Scalars['Boolean']
canUpdateAllObjectRecords: Scalars['Boolean']
canSoftDeleteAllObjectRecords: Scalars['Boolean']
canDestroyAllObjectRecords: Scalars['Boolean']
canUpdateAllSettings: Scalars['Boolean']
canAccessAllTools: Scalars['Boolean']
objectPermissions: MarketplaceAppRoleObjectPermission[]
fieldPermissions: MarketplaceAppRoleFieldPermission[]
permissionFlags: Scalars['String'][]
__typename: 'MarketplaceAppDefaultRole'
}
export interface MarketplaceApp {
id: Scalars['String']
name: Scalars['String']
description: Scalars['String']
icon: Scalars['String']
version: Scalars['String']
author: Scalars['String']
category: Scalars['String']
logo?: Scalars['String']
screenshots: Scalars['String'][]
aboutDescription: Scalars['String']
providers: Scalars['String'][]
websiteUrl?: Scalars['String']
termsUrl?: Scalars['String']
objects: MarketplaceAppObject[]
fields: MarketplaceAppField[]
logicFunctions: MarketplaceAppLogicFunction[]
frontComponents: MarketplaceAppFrontComponent[]
defaultRole?: MarketplaceAppDefaultRole
sourcePackage?: Scalars['String']
isFeatured: Scalars['Boolean']
__typename: 'MarketplaceApp'
}
export interface MarketplaceAppDetail {
universalIdentifier: Scalars['String']
id: Scalars['String']
name: Scalars['String']
sourceType: ApplicationRegistrationSourceType
sourcePackage?: Scalars['String']
latestAvailableVersion?: Scalars['String']
isListed: Scalars['Boolean']
isFeatured: Scalars['Boolean']
manifest?: Scalars['JSON']
__typename: 'MarketplaceAppDetail'
}
export interface PublicDomain {
id: Scalars['UUID']
domain: Scalars['String']
@@ -2805,6 +2733,7 @@ export interface Query {
findApplicationRegistrationStats: ApplicationRegistrationStats
findApplicationRegistrationVariables: ApplicationRegistrationVariable[]
applicationRegistrationTarballUrl?: Scalars['String']
getApplicationShareLink: Scalars['String']
currentUser: User
currentWorkspace: Workspace
getPublicWorkspaceDataByDomain: PublicWorkspaceData
@@ -2830,7 +2759,7 @@ export interface Query {
findManyPublicDomains: PublicDomain[]
getEmailingDomains: EmailingDomain[]
findManyMarketplaceApps: MarketplaceApp[]
findOneMarketplaceApp: MarketplaceApp
findMarketplaceAppDetail: MarketplaceAppDetail
findManyApplications: Application[]
findOneApplication: Application
__typename: 'Query'
@@ -3047,6 +2976,7 @@ export interface Mutation {
verifyEmailingDomain: EmailingDomain
createOneAppToken: AppToken
installMarketplaceApp: Scalars['Boolean']
syncMarketplaceCatalog: Scalars['Boolean']
installApplication: Scalars['Boolean']
runWorkspaceMigration: Scalars['Boolean']
uninstallApplication: Scalars['Boolean']
@@ -3114,9 +3044,6 @@ export interface ApplicationRegistrationGenqlSelection{
id?: boolean | number
universalIdentifier?: boolean | number
name?: boolean | number
description?: boolean | number
logoUrl?: boolean | number
author?: boolean | number
oAuthClientId?: boolean | number
oAuthRedirectUris?: boolean | number
oAuthScopes?: boolean | number
@@ -3124,8 +3051,6 @@ export interface ApplicationRegistrationGenqlSelection{
sourceType?: boolean | number
sourcePackage?: boolean | number
latestAvailableVersion?: boolean | number
websiteUrl?: boolean | number
termsUrl?: boolean | number
isListed?: boolean | number
isFeatured?: boolean | number
createdAt?: boolean | number
@@ -5314,107 +5239,34 @@ export interface FileGenqlSelection{
__scalar?: boolean | number
}
export interface MarketplaceAppFieldGenqlSelection{
name?: boolean | number
type?: boolean | number
label?: boolean | number
description?: boolean | number
icon?: boolean | number
objectUniversalIdentifier?: boolean | number
universalIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MarketplaceAppObjectGenqlSelection{
universalIdentifier?: boolean | number
nameSingular?: boolean | number
namePlural?: boolean | number
labelSingular?: boolean | number
labelPlural?: boolean | number
description?: boolean | number
icon?: boolean | number
fields?: MarketplaceAppFieldGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MarketplaceAppLogicFunctionGenqlSelection{
name?: boolean | number
description?: boolean | number
timeoutSeconds?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MarketplaceAppFrontComponentGenqlSelection{
name?: boolean | number
description?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MarketplaceAppRoleObjectPermissionGenqlSelection{
objectUniversalIdentifier?: boolean | number
canReadObjectRecords?: boolean | number
canUpdateObjectRecords?: boolean | number
canSoftDeleteObjectRecords?: boolean | number
canDestroyObjectRecords?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MarketplaceAppRoleFieldPermissionGenqlSelection{
objectUniversalIdentifier?: boolean | number
fieldUniversalIdentifier?: boolean | number
canReadFieldValue?: boolean | number
canUpdateFieldValue?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MarketplaceAppDefaultRoleGenqlSelection{
id?: boolean | number
label?: boolean | number
description?: boolean | number
canReadAllObjectRecords?: boolean | number
canUpdateAllObjectRecords?: boolean | number
canSoftDeleteAllObjectRecords?: boolean | number
canDestroyAllObjectRecords?: boolean | number
canUpdateAllSettings?: boolean | number
canAccessAllTools?: boolean | number
objectPermissions?: MarketplaceAppRoleObjectPermissionGenqlSelection
fieldPermissions?: MarketplaceAppRoleFieldPermissionGenqlSelection
permissionFlags?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MarketplaceAppGenqlSelection{
id?: boolean | number
name?: boolean | number
description?: boolean | number
icon?: boolean | number
version?: boolean | number
author?: boolean | number
category?: boolean | number
logo?: boolean | number
screenshots?: boolean | number
aboutDescription?: boolean | number
providers?: boolean | number
websiteUrl?: boolean | number
termsUrl?: boolean | number
objects?: MarketplaceAppObjectGenqlSelection
fields?: MarketplaceAppFieldGenqlSelection
logicFunctions?: MarketplaceAppLogicFunctionGenqlSelection
frontComponents?: MarketplaceAppFrontComponentGenqlSelection
defaultRole?: MarketplaceAppDefaultRoleGenqlSelection
sourcePackage?: boolean | number
isFeatured?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MarketplaceAppDetailGenqlSelection{
universalIdentifier?: boolean | number
id?: boolean | number
name?: boolean | number
sourceType?: boolean | number
sourcePackage?: boolean | number
latestAvailableVersion?: boolean | number
isListed?: boolean | number
isFeatured?: boolean | number
manifest?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface PublicDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
@@ -6042,6 +5894,7 @@ export interface QueryGenqlSelection{
findApplicationRegistrationStats?: (ApplicationRegistrationStatsGenqlSelection & { __args: {id: Scalars['String']} })
findApplicationRegistrationVariables?: (ApplicationRegistrationVariableGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} })
applicationRegistrationTarballUrl?: { __args: {id: Scalars['String']} }
getApplicationShareLink?: { __args: {id: Scalars['String']} }
currentUser?: UserGenqlSelection
currentWorkspace?: WorkspaceGenqlSelection
getPublicWorkspaceDataByDomain?: (PublicWorkspaceDataGenqlSelection & { __args?: {origin?: (Scalars['String'] | null)} })
@@ -6067,7 +5920,7 @@ export interface QueryGenqlSelection{
findManyPublicDomains?: PublicDomainGenqlSelection
getEmailingDomains?: EmailingDomainGenqlSelection
findManyMarketplaceApps?: MarketplaceAppGenqlSelection
findOneMarketplaceApp?: (MarketplaceAppGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
findMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
findManyApplications?: ApplicationGenqlSelection
findOneApplication?: (ApplicationGenqlSelection & { __args?: {id?: (Scalars['UUID'] | null), universalIdentifier?: (Scalars['UUID'] | null)} })
__typename?: boolean | number
@@ -6303,6 +6156,7 @@ export interface MutationGenqlSelection{
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
createOneAppToken?: (AppTokenGenqlSelection & { __args: {input: CreateOneAppTokenInput} })
installMarketplaceApp?: { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} }
syncMarketplaceCatalog?: boolean | number
installApplication?: { __args: {appRegistrationId: Scalars['String'], version?: (Scalars['String'] | null)} }
runWorkspaceMigration?: { __args: {workspaceMigration: WorkspaceMigrationInput} }
uninstallApplication?: { __args: {universalIdentifier: Scalars['String']} }
@@ -6597,11 +6451,11 @@ export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String']
export interface GetAuthorizationUrlForSSOInput {identityProviderId: Scalars['UUID'],workspaceInviteHash?: (Scalars['String'] | null)}
export interface CreateApplicationRegistrationInput {name: Scalars['String'],description?: (Scalars['String'] | null),logoUrl?: (Scalars['String'] | null),author?: (Scalars['String'] | null),universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),websiteUrl?: (Scalars['String'] | null),termsUrl?: (Scalars['String'] | null)}
export interface CreateApplicationRegistrationInput {name: Scalars['String'],universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null)}
export interface UpdateApplicationRegistrationInput {id: Scalars['String'],update: UpdateApplicationRegistrationPayload}
export interface UpdateApplicationRegistrationPayload {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),logoUrl?: (Scalars['String'] | null),author?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),websiteUrl?: (Scalars['String'] | null),termsUrl?: (Scalars['String'] | null),isListed?: (Scalars['Boolean'] | null)}
export interface UpdateApplicationRegistrationPayload {name?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),isListed?: (Scalars['Boolean'] | null)}
export interface CreateApplicationRegistrationVariableInput {applicationRegistrationId: Scalars['String'],key: Scalars['String'],value: Scalars['String'],description?: (Scalars['String'] | null),isSecret?: (Scalars['Boolean'] | null)}
@@ -8295,62 +8149,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const MarketplaceAppField_possibleTypes: string[] = ['MarketplaceAppField']
export const isMarketplaceAppField = (obj?: { __typename?: any } | null): obj is MarketplaceAppField => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppField"')
return MarketplaceAppField_possibleTypes.includes(obj.__typename)
}
const MarketplaceAppObject_possibleTypes: string[] = ['MarketplaceAppObject']
export const isMarketplaceAppObject = (obj?: { __typename?: any } | null): obj is MarketplaceAppObject => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppObject"')
return MarketplaceAppObject_possibleTypes.includes(obj.__typename)
}
const MarketplaceAppLogicFunction_possibleTypes: string[] = ['MarketplaceAppLogicFunction']
export const isMarketplaceAppLogicFunction = (obj?: { __typename?: any } | null): obj is MarketplaceAppLogicFunction => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppLogicFunction"')
return MarketplaceAppLogicFunction_possibleTypes.includes(obj.__typename)
}
const MarketplaceAppFrontComponent_possibleTypes: string[] = ['MarketplaceAppFrontComponent']
export const isMarketplaceAppFrontComponent = (obj?: { __typename?: any } | null): obj is MarketplaceAppFrontComponent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppFrontComponent"')
return MarketplaceAppFrontComponent_possibleTypes.includes(obj.__typename)
}
const MarketplaceAppRoleObjectPermission_possibleTypes: string[] = ['MarketplaceAppRoleObjectPermission']
export const isMarketplaceAppRoleObjectPermission = (obj?: { __typename?: any } | null): obj is MarketplaceAppRoleObjectPermission => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppRoleObjectPermission"')
return MarketplaceAppRoleObjectPermission_possibleTypes.includes(obj.__typename)
}
const MarketplaceAppRoleFieldPermission_possibleTypes: string[] = ['MarketplaceAppRoleFieldPermission']
export const isMarketplaceAppRoleFieldPermission = (obj?: { __typename?: any } | null): obj is MarketplaceAppRoleFieldPermission => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppRoleFieldPermission"')
return MarketplaceAppRoleFieldPermission_possibleTypes.includes(obj.__typename)
}
const MarketplaceAppDefaultRole_possibleTypes: string[] = ['MarketplaceAppDefaultRole']
export const isMarketplaceAppDefaultRole = (obj?: { __typename?: any } | null): obj is MarketplaceAppDefaultRole => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppDefaultRole"')
return MarketplaceAppDefaultRole_possibleTypes.includes(obj.__typename)
}
const MarketplaceApp_possibleTypes: string[] = ['MarketplaceApp']
export const isMarketplaceApp = (obj?: { __typename?: any } | null): obj is MarketplaceApp => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceApp"')
@@ -8359,6 +8157,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const MarketplaceAppDetail_possibleTypes: string[] = ['MarketplaceAppDetail']
export const isMarketplaceAppDetail = (obj?: { __typename?: any } | null): obj is MarketplaceAppDetail => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMarketplaceAppDetail"')
return MarketplaceAppDetail_possibleTypes.includes(obj.__typename)
}
const PublicDomain_possibleTypes: string[] = ['PublicDomain']
export const isPublicDomain = (obj?: { __typename?: any } | null): obj is PublicDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPublicDomain"')
File diff suppressed because it is too large Load Diff
@@ -19,15 +19,17 @@ The SDK provides helper functions for defining your app entities. As described i
|----------|---------|
| `defineApplication` | Configure application metadata (required, one per app) |
| `defineObject` | Define custom objects with fields |
| `defineField` | Extend existing objects with additional fields or define standalone relation fields |
| `defineLogicFunction` | Define logic functions with handlers |
| `definePreInstallLogicFunction` | Define a pre-install logic function (one per app) |
| `definePostInstallLogicFunction` | Define a post-install logic function (one per app) |
| `defineFrontComponent` | Define front components for custom UI |
| `defineRole` | Configure role permissions and object access |
| `defineField` | Extend existing objects with additional fields |
| `defineView` | Define saved views for objects |
| `defineNavigationMenuItem` | Define sidebar navigation links |
| `defineSkill` | Define AI agent skills |
| `defineAgent` | Define AI agents |
| `definePageLayout` | Define custom page layouts |
These functions validate your configuration at build time and provide IDE autocompletion and type safety.
@@ -36,7 +38,7 @@ These functions validate your configuration at build time and provide IDE autoco
Custom objects describe both schema and behavior for records in your workspace. Use `defineObject()` to define objects with built-in validation:
```typescript
// src/app/postCard.object.ts
// src/objects/postCard.object.ts
import { defineObject, FieldType } from 'twenty-sdk';
enum PostCardStatus {
@@ -110,7 +112,7 @@ Key points:
- The `universalIdentifier` must be unique and stable across deployments.
- Each field requires a `name`, `type`, `label`, and its own stable `universalIdentifier`.
- The `fields` array is optional — you can define objects without custom fields.
- You can scaffold new objects using `yarn twenty entity:add`, which guides you through naming, fields, and relationships.
- You can scaffold new objects using `yarn twenty add`, which guides you through naming, fields, and relationships.
<Note>
**Base fields are created automatically.** When you define a custom object, Twenty automatically adds standard fields
@@ -120,6 +122,195 @@ Key points:
but this is not recommended.
</Note>
### Defining fields on existing objects
Use `defineField()` to add fields to objects you don't own — such as standard Twenty objects (Person, Company, etc.) or objects from other apps. Unlike inline fields in `defineObject()`, standalone fields require an `objectUniversalIdentifier` to specify which object they extend:
```typescript
// src/fields/company-loyalty-tier.field.ts
import { defineField, FieldType } from 'twenty-sdk';
export default defineField({
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
name: 'loyaltyTier',
type: FieldType.SELECT,
label: 'Loyalty Tier',
icon: 'IconStar',
options: [
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
],
});
```
Key points:
- `objectUniversalIdentifier` identifies the target object. For standard objects, use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS` exported from `twenty-sdk`.
- When defining fields inline in `defineObject()`, you do **not** need `objectUniversalIdentifier` — it's inherited from the parent object.
- `defineField()` is the only way to add fields to objects you didn't create with `defineObject()`.
### Relations
Relations connect objects together. In Twenty, relations are always **bidirectional** — you define both sides, and each side references the other.
There are two relation types:
| Relation type | Description | Has foreign key? |
|---------------|-------------|-----------------|
| `MANY_TO_ONE` | Many records of this object point to one record of the target | Yes (`joinColumnName`) |
| `ONE_TO_MANY` | One record of this object has many records of the target | No (inverse side) |
#### How relations work
Every relation requires **two fields** that reference each other:
1. The **MANY_TO_ONE** side — lives on the object that holds the foreign key
2. The **ONE_TO_MANY** side — lives on the object that owns the collection
Both fields use `FieldType.RELATION` and cross-reference each other via `relationTargetFieldMetadataUniversalIdentifier`.
#### Example: Post Card has many Recipients
Suppose a `PostCard` can be sent to many `PostCardRecipient` records. Each recipient belongs to exactly one post card.
**Step 1: Define the ONE_TO_MANY side on PostCard** (the "one" side):
```typescript
// src/fields/post-card-recipients-on-post-card.field.ts
import { defineField, FieldType, RelationType } from 'twenty-sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
// Export so the other side can reference it
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
// Import from the other side
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
icon: 'IconUsers',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
```
**Step 2: Define the MANY_TO_ONE side on PostCardRecipient** (the "many" side — holds the foreign key):
```typescript
// src/fields/post-card-on-post-card-recipient.field.ts
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
// Export so the other side can reference it
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
// Import from the other side
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
export default defineField({
universalIdentifier: POST_CARD_FIELD_ID,
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCard',
label: 'Post Card',
icon: 'IconMail',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'postCardId',
},
});
```
<Note>
**Circular imports:** Both relation fields reference each other's `universalIdentifier`. To avoid circular import issues, export your field IDs as named constants from each file, and import them in the other file. The build system resolves these at compile time.
</Note>
#### Relating to standard objects
To create a relation with a built-in Twenty object (Person, Company, etc.), use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`:
```typescript
// src/fields/person-on-self-hosting-user.field.ts
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';
export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';
export default defineField({
universalIdentifier: PERSON_FIELD_ID,
objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
description: 'Person matching with the self hosting user',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
});
```
#### Relation field properties
| Property | Required | Description |
|----------|----------|-------------|
| `type` | Yes | Must be `FieldType.RELATION` |
| `relationTargetObjectMetadataUniversalIdentifier` | Yes | The `universalIdentifier` of the target object |
| `relationTargetFieldMetadataUniversalIdentifier` | Yes | The `universalIdentifier` of the matching field on the target object |
| `universalSettings.relationType` | Yes | `RelationType.MANY_TO_ONE` or `RelationType.ONE_TO_MANY` |
| `universalSettings.onDelete` | MANY_TO_ONE only | What happens when the referenced record is deleted: `CASCADE`, `SET_NULL`, `RESTRICT`, or `NO_ACTION` |
| `universalSettings.joinColumnName` | MANY_TO_ONE only | Database column name for the foreign key (e.g., `postCardId`) |
#### Inline relation fields in defineObject
You can also define relation fields directly inside `defineObject()`. In that case, omit `objectUniversalIdentifier` — it's inherited from the parent object:
```typescript
export default defineObject({
universalIdentifier: '...',
nameSingular: 'postCardRecipient',
// ...
fields: [
{
universalIdentifier: POST_CARD_FIELD_ID,
type: FieldType.RELATION,
name: 'postCard',
label: 'Post Card',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'postCardId',
},
},
// ... other fields
],
});
```
### Application config (application-config.ts)
@@ -161,13 +352,29 @@ Notes:
- `defaultRoleUniversalIdentifier` must match the role file (see below).
- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions).
#### Marketplace metadata
If you plan to [publish your app](/developers/extend/apps/publishing), these optional fields control how your app appears in the marketplace:
| Field | Description |
|-------|-------------|
| `author` | Author or company name |
| `category` | App category for marketplace filtering |
| `logoUrl` | Path to your app logo (relative to `./assets/`) |
| `screenshots` | Array of screenshot paths (relative to `./assets/`) |
| `aboutDescription` | Longer markdown description for the "About" tab |
| `websiteUrl` | Link to your website |
| `termsUrl` | Link to terms of service |
| `emailSupport` | Support email address |
| `issueReportUrl` | Link to issue tracker |
#### Roles and permissions
Applications can define roles that encapsulate permissions on your workspace's objects and actions. The field `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions.
- The runtime API key injected as `TWENTY_API_KEY` is derived from this default function role.
- The typed client will be restricted to the permissions granted to that role.
- Follow leastprivilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier.
- Follow least-privilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier.
##### Default function role (*.role.ts)
@@ -219,7 +426,7 @@ The `universalIdentifier` of this role is then referenced in `application-config
- **application-config.ts** points to that role so your functions inherit its permissions.
Notes:
- Start from the scaffolded role, then progressively restrict it following leastprivilege.
- Start from the scaffolded role, then progressively restrict it following least-privilege.
- Replace the `objectPermissions` and `fieldPermissions` with the objects/fields your functions need.
- `permissionFlags` control access to platform-level capabilities. Keep them minimal; add only what you need.
- See a working example in the Hello World app: [`packages/twenty-apps/hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts).
@@ -229,7 +436,7 @@ Notes:
Each function file uses `defineLogicFunction()` to export a configuration with a handler and optional triggers.
```typescript
// src/app/createPostCard.logic-function.ts
// src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
@@ -318,7 +525,7 @@ export default definePreInstallLogicFunction({
You can also manually execute the pre-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --preInstall
yarn twenty exec --preInstall
```
Key points:
@@ -327,7 +534,7 @@ Key points:
- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected.
- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks.
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `function:execute --preInstall`.
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `exec --preInstall`.
### Post-install functions
@@ -355,7 +562,7 @@ export default definePostInstallLogicFunction({
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
yarn twenty exec --postInstall
```
Key points:
@@ -364,7 +571,7 @@ Key points:
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `exec --postInstall`.
### Route trigger payload
@@ -412,7 +619,7 @@ The `RoutePayload` type has the following structure:
|----------|------|-------------|
| `headers` | `Record<string, string \| undefined>` | HTTP headers (only those listed in `forwardedRequestHeaders`) |
| `queryStringParameters` | `Record<string, string \| undefined>` | Query string parameters (multiple values joined with commas) |
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern (e.g., `/users/:id` `{ id: '123' }`) |
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern (e.g., `/users/:id` -> `{ id: '123' }`) |
| `body` | `object \| null` | Parsed request body (JSON) |
| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded |
| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) |
@@ -458,7 +665,7 @@ const handler = async (event: RoutePayload) => {
You can create new functions in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new logic function. This generates a starter file with a handler and config.
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new logic function. This generates a starter file with a handler and config.
- **Manual**: Create a new `*.logic-function.ts` file and use `defineLogicFunction()`, following the same pattern.
### Marking a logic function as a tool
@@ -470,7 +677,7 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
```typescript
// src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
@@ -558,7 +765,7 @@ Key points:
You can create new front components in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new front component.
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new front component.
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
### Skills
@@ -592,46 +799,122 @@ Key points:
You can create new skills in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new skill.
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
### Generated typed clients
### Typed API clients (`twenty-client-sdk`)
Two typed clients are auto-generated by `yarn twenty dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
The `twenty-client-sdk` package provides two typed GraphQL clients for interacting with the Twenty API from your logic functions and front components:
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
| Client | Import | Endpoint | Generated? |
|--------|--------|----------|------------|
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — workspace data (records, objects) | Yes, at dev/build time |
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — workspace config, file uploads | No, ships pre-built |
#### CoreApiClient
`CoreApiClient` is the main client for querying and mutating workspace data. It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty build`, so it's fully typed to match your objects and fields.
```typescript
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
import { CoreApiClient } from 'twenty-client-sdk/core';
const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
// Query records
const { companies } = await client.query({
companies: {
edges: {
node: {
id: true,
name: true,
domainName: true,
},
},
},
});
// Create a record
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Acme Corp',
},
},
id: true,
name: true,
},
});
```
Both clients are re-generated automatically by `yarn twenty dev` whenever your objects or fields change.
The client uses a selection-set syntax: pass `true` to include a field, use `__args` for arguments, and nest objects for relations. You get full autocompletion and type checking based on your workspace schema.
#### Runtime credentials in logic functions
<Note>
**CoreApiClient is generated at dev/build time.** If you try to use it without running `yarn twenty dev` or `yarn twenty build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema, generates a typed client using `@genql/cli`, writes the generated sources to `node_modules/twenty-client-sdk/dist/core/generated/`, and replaces the stubs in `node_modules/twenty-client-sdk/dist/core.mjs` and `node_modules/twenty-client-sdk/dist/core.cjs`.
</Note>
When your function runs on Twenty, the platform injects credentials as environment variables before your code executes:
#### Using CoreSchema for type annotations
- `TWENTY_API_URL`: Base URL of the Twenty API your app targets.
- `TWENTY_API_KEY`: Shortlived key scoped to your application's default function role.
`CoreSchema` provides TypeScript types matching your workspace objects, useful for typing component state or function parameters:
Notes:
- You do not need to pass URL or API key to the generated client. It reads `TWENTY_API_URL` and `TWENTY_API_KEY` from process.env at runtime.
- The API key's permissions are determined by the role referenced in your `application-config.ts` via `defaultRoleUniversalIdentifier`. This is the default role used by logic functions of your application.
- Applications can define roles to follow leastprivilege. Grant only the permissions your functions need, then point `defaultRoleUniversalIdentifier` to that role's universal identifier.
```typescript
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { useState } from 'react';
const [company, setCompany] = useState<
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
>(undefined);
const client = new CoreApiClient();
const result = await client.query({
company: {
__args: { filter: { position: { eq: 1 } } },
id: true,
name: true,
},
});
setCompany(result.company);
```
#### MetadataApiClient
`MetadataApiClient` ships pre-built with the SDK (no generation required). It queries the `/metadata` endpoint for workspace configuration, applications, and file uploads:
```typescript
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadataClient = new MetadataApiClient();
// Query workspace info
const { currentWorkspace } = await metadataClient.query({
currentWorkspace: { id: true, displayName: true },
});
// List installed applications
const { findManyApplications } = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
version: true,
},
});
```
#### Runtime credentials
When your code runs on Twenty (logic functions or front components), the platform injects credentials as environment variables:
- `TWENTY_API_URL` — Base URL of the Twenty API
- `TWENTY_API_KEY` — Short-lived key scoped to your application's default function role
You do **not** need to pass these to the clients — they read from `process.env` automatically. The API key's permissions are determined by the role referenced in `defaultRoleUniversalIdentifier` in your `application-config.ts`.
#### Uploading files
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
`MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields. It implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec):
```typescript
import { MetadataApiClient } from 'twenty-sdk/generated';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
@@ -641,25 +924,14 @@ const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
'application/pdf', // MIME type
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
);
console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
```
The method signature:
```typescript
uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string,
fieldMetadataUniversalIdentifier: string,
): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }>
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `fileBuffer` | `Buffer` | The raw file contents |
@@ -668,8 +940,7 @@ uploadFile(
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
Key points:
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else.
- Uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed.
- The returned `url` is a signed URL you can use to access the uploaded file.
### Hello World example
@@ -48,7 +48,7 @@ From here you can:
```bash filename="Terminal"
# Add a new entity to your application (guided)
yarn twenty entity:add
yarn twenty add
# Watch your application's function logs
yarn twenty function:logs
@@ -122,7 +122,7 @@ With `--minimal`, only the core files are created (`application-config.ts`, `rol
At a high level:
- **package.json**: Declares the app name, version, engines (Node 24+, Yarn 4), and adds `twenty-sdk` plus a `twenty` script that delegates to the local `twenty` CLI. Run `yarn twenty help` to list all available commands.
- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `generated/` (typed client), `dist/`, `build/`, coverage folders, log files, and `.env*` files.
- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `.twenty/`, `dist/`, `build/`, coverage folders, log files, and `.env*` files.
- **yarn.lock**, **.yarnrc.yml**, **.yarn/**: Lock and configure the Yarn 4 toolchain used by the project.
- **.nvmrc**: Pins the Node.js version expected by the project.
- **.oxlintrc.json** and **tsconfig.json**: Provide linting and TypeScript configuration for your app's TypeScript sources.
@@ -165,8 +165,8 @@ export default defineObject({
Later commands will add more files and folders:
- `yarn twenty dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
- `yarn twenty dev` will auto-generate the typed `CoreApiClient` (for workspace data via `/graphql`) into `node_modules/twenty-client-sdk/`. The `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built and is available immediately. Import them from `twenty-client-sdk/core` and `twenty-client-sdk/metadata` respectively.
- `yarn twenty add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
## Authentication
@@ -12,7 +12,25 @@ Apps are currently in alpha testing. The feature is functional but still evolvin
Once your app is [built and tested locally](/developers/extend/apps/building), you have two paths for distributing it:
- **Publish to npm** — list your app in the Twenty marketplace for any workspace to discover and install.
- **Push a tarball** — deploy your app to a specific Twenty server for internal use without making it publicly available.
- **Deploy a tarball** — upload your app directly to a specific Twenty server for internal or private use.
Both paths start from the same **build** step.
## Building your app
The `build` command compiles your TypeScript sources, transpiles logic functions and front components, and generates a `manifest.json` that describes your app's contents:
```bash filename="Terminal"
yarn twenty build
```
The output is written to `.twenty/output/`. This directory contains everything needed for distribution: compiled code, assets, the manifest, and a copy of your `package.json`.
To also create a `.tgz` tarball (used by the deploy command internally, or for manual distribution):
```bash filename="Terminal"
yarn twenty build --tarball
```
## Publishing to npm
@@ -21,29 +39,72 @@ Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twe
### Requirements
- An [npm](https://www.npmjs.com) account
- Your package name **must** use the `twenty-app-` prefix (e.g., `twenty-app-postcard-sender`)
- The `twenty-app` keyword **must** be listed in your `package.json` `keywords` array
### Adding the required keyword
The Twenty marketplace discovers apps by searching the npm registry for packages with the `twenty-app` keyword. Add it to your `package.json`:
```json filename="package.json"
{
"name": "twenty-app-postcard-sender",
"version": "1.0.0",
"keywords": ["twenty-app"],
...
}
```
<Note>
The marketplace searches for `keywords:twenty-app` on the npm registry. Without this keyword, your package won't appear in the marketplace even if it has the `twenty-app-` name prefix.
</Note>
### Steps
1. **Build your app** — the CLI compiles your TypeScript sources and generates the application manifest:
1. **Build your app:**
```bash filename="Terminal"
yarn twenty build
```
2. **Publish to npm** — push the built package to the npm registry:
2. **Publish to npm:**
```bash filename="Terminal"
npx twenty publish
yarn twenty publish
```
### Auto-discovery
This runs `npm publish` from the `.twenty/output/` directory.
Packages with the `twenty-app-` prefix are automatically discovered by the Twenty marketplace catalog. Once published, your app appears in the marketplace within a few minutes — no manual registration or approval required.
To publish under a specific dist-tag (e.g., `beta` or `next`):
```bash filename="Terminal"
yarn twenty publish --tag beta
```
### How marketplace discovery works
The Twenty server syncs its marketplace catalog from the npm registry **every hour**:
1. It searches for all npm packages with the `keywords:twenty-app` keyword
2. For each package, it fetches the `manifest.json` from the npm CDN
3. The app's metadata (name, description, author, logo, screenshots, category) is extracted from the manifest and displayed in the marketplace
After publishing, your app can take up to one hour to appear in the marketplace. To trigger the sync immediately instead of waiting for the next hourly run:
```bash filename="Terminal"
yarn twenty catalog-sync
```
To target a specific remote:
```bash filename="Terminal"
yarn twenty catalog-sync -r production
```
The metadata shown in the marketplace comes from your `defineApplication()` call in your app source code — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`.
### CI publishing
The scaffolded project includes a GitHub Actions workflow that publishes on every release. It runs `app:build`, then `npm publish --provenance` from the build output:
The scaffolded project includes a GitHub Actions workflow that publishes on every release:
```yaml filename=".github/workflows/publish.yml"
name: Publish
@@ -72,48 +133,117 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `npx twenty build`, then `npm publish` from `.twenty/output`.
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `yarn twenty build`, then `npm publish` from `.twenty/output`.
<Tip>
**npm provenance** is optional but recommended. Publishing with `--provenance` adds a trust badge to your npm listing, letting users verify the package was built from a specific commit in a public CI pipeline. See the [npm provenance docs](https://docs.npmjs.com/generating-provenance-statements) for setup instructions.
</Tip>
## Internal distribution
## Deploying to a server (tarball)
For apps you don't want publicly available — proprietary tools, enterprise-only integrations, or experimental builds — you can push a tarball directly to a Twenty server.
For apps you don't want publicly available — proprietary tools, enterprise-only integrations, or experimental builds — you can deploy a tarball directly to a Twenty server.
### Push a tarball
### Prerequisites
Build your app and deploy it to a specific server in one step:
Before deploying, you need a configured remote pointing to the target server. Remotes store the server URL and authentication credentials locally in `~/.twenty/config.json`.
Add a remote:
```bash filename="Terminal"
npx twenty publish --server <server-url>
yarn twenty remote add --url https://your-twenty-server.com --as production
```
Any workspace on that server can then install and upgrade the app from the **Applications** settings page.
For a local development server:
```bash filename="Terminal"
yarn twenty remote add --local --as local
```
You can also authenticate with an API key for non-interactive environments:
```bash filename="Terminal"
yarn twenty remote add --url https://your-twenty-server.com --token <api-key> --as production
```
Manage your remotes:
```bash filename="Terminal"
yarn twenty remote list # List all configured remotes
yarn twenty remote switch prod # Set the default remote
yarn twenty remote status # Show active remote and auth status
yarn twenty remote remove old # Remove a remote
```
### Deploying
Build and upload your app to the server in one step:
```bash filename="Terminal"
yarn twenty deploy
```
This builds the app with `--tarball`, then uploads the tarball to the default remote via a GraphQL multipart upload.
To deploy to a specific remote:
```bash filename="Terminal"
yarn twenty deploy -r production
```
### Sharing a deployed app
Tarball apps are not listed in the public marketplace, so other workspaces on the same server won't discover them by browsing. To share a deployed app:
1. Go to **Settings > Applications > Registrations** and open your app
2. In the **Distribution** tab, click **Copy share link**
3. Share this link with users on other workspaces — it takes them directly to the app's install page
The share link uses the server's base URL (without any workspace subdomain) so it works for any workspace on the server.
### Version management
To release an update:
1. Bump the `version` field in your `package.json`
2. Push a new tarball with `npx twenty publish --server <server-url>`
3. Workspaces on that server will see the upgrade available in their settings
2. Run `yarn twenty deploy` (or `yarn twenty deploy -r production`)
3. Workspaces that have the app installed will see the upgrade available in their settings
<Note>
Internal apps are scoped to the server they're pushed to. They won't appear in the public marketplace and can't be installed by workspaces on other servers.
</Note>
## Installing apps
## App categories
Once an app is published (npm) or deployed (tarball), workspaces install it through the UI:
```bash filename="Terminal"
yarn twenty install
```
Or from the **Settings > Applications** page in the Twenty UI, where both marketplace and tarball-deployed apps can be browsed and installed.
## App distribution categories
Twenty organizes apps into three categories based on how they're distributed:
| Category | How it works | Visible in marketplace? |
|----------|-------------|------------------------|
| **Development** | Local dev mode apps running via `yarn twenty dev`. Used for building and testing. | No |
| **Published** | Apps published to npm with the `twenty-app-` prefix. Listed in the marketplace for any workspace to install. | Yes |
| **Internal** | Apps deployed via tarball to a specific server. Available only to workspaces on that server. | No |
| **Published (npm)** | Apps published to npm with the `twenty-app` keyword. Listed in the marketplace for any workspace to install. | Yes |
| **Internal (tarball)** | Apps deployed via tarball to a specific server. Available only to workspaces on that server via a share link. | No |
<Tip>
Start in **Development** mode while building your app. When it's ready, choose **Published** (npm) for broad distribution or **Internal** (tarball) for private deployment.
</Tip>
## CLI reference
| Command | Description | Key flags |
|---------|-------------|-----------|
| `yarn twenty build` | Compile app and generate manifest | `--tarball` — also create a `.tgz` package |
| `yarn twenty publish` | Build and publish to npm | `--tag <tag>` — npm dist-tag (e.g., `beta`, `next`) |
| `yarn twenty deploy` | Build and upload tarball to a server | `-r, --remote <name>` — target remote |
| `yarn twenty catalog-sync` | Trigger marketplace catalog sync on the server | `-r, --remote <name>` — target remote |
| `yarn twenty install` | Install a deployed app on a workspace | `-r, --remote <name>` — target remote |
| `yarn twenty dev` | Watch and sync local changes | Uses default remote |
| `yarn twenty remote add` | Add a server connection | `--url`, `--token`, `--as`, `--local`, `--port` |
| `yarn twenty remote list` | List configured remotes | — |
| `yarn twenty remote switch` | Set default remote | — |
| `yarn twenty remote status` | Show connection status | — |
| `yarn twenty remote remove` | Remove a remote | — |
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ export const StyledMarkdownContainer = styled.div`
border-radius: ${themeCssVariables.border.radius.sm};
line-height: 150%;
margin: ${themeCssVariables.spacing['1.5']} 0;
overflow-x: auto;
position: relative;
scroll-margin-bottom: ${themeCssVariables.spacing[10]};
scroll-margin-top: ${themeCssVariables.spacing[10]};
@@ -134,6 +135,7 @@ export const StyledMarkdownContainer = styled.div`
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md} !important;
overflow-x: auto;
}
.markdown-block-code * {
@@ -1,9 +1,11 @@
import { gql } from '@apollo/client';
import { APPLICATION_FRAGMENT } from '@/applications/graphql/fragments/applicationFragment';
export const FIND_ONE_APPLICATION_BY_UNIVERSAL_IDENTIFIER = gql`
${APPLICATION_FRAGMENT}
query FindOneApplicationByUniversalIdentifier($universalIdentifier: UUID!) {
findOneApplication(universalIdentifier: $universalIdentifier) {
id
...ApplicationFields
}
}
`;
@@ -0,0 +1,15 @@
import gql from 'graphql-tag';
export const MARKETPLACE_APP_DETAIL_FRAGMENT = gql`
fragment MarketplaceAppDetailFields on MarketplaceAppDetail {
id
universalIdentifier
name
sourceType
sourcePackage
latestAvailableVersion
isListed
isFeatured
manifest
}
`;
@@ -6,75 +6,10 @@ export const MARKETPLACE_APP_FRAGMENT = gql`
name
description
icon
version
author
category
logo
screenshots
aboutDescription
providers
websiteUrl
termsUrl
objects {
universalIdentifier
nameSingular
namePlural
labelSingular
labelPlural
description
icon
fields {
universalIdentifier
name
type
label
description
icon
}
}
fields {
name
type
label
description
icon
objectUniversalIdentifier
}
logicFunctions {
name
description
timeoutSeconds
}
frontComponents {
name
description
}
sourcePackage
isFeatured
defaultRole {
id
label
description
canReadAllObjectRecords
canUpdateAllObjectRecords
canSoftDeleteAllObjectRecords
canDestroyAllObjectRecords
canUpdateAllSettings
canAccessAllTools
objectPermissions {
objectUniversalIdentifier
canReadObjectRecords
canUpdateObjectRecords
canSoftDeleteObjectRecords
canDestroyObjectRecords
}
fieldPermissions {
objectUniversalIdentifier
fieldUniversalIdentifier
canReadFieldValue
canUpdateFieldValue
}
permissionFlags
}
}
`;
@@ -0,0 +1,12 @@
import gql from 'graphql-tag';
import { MARKETPLACE_APP_DETAIL_FRAGMENT } from '@/marketplace/graphql/fragments/marketplaceAppDetailFragment';
export const FIND_MARKETPLACE_APP_DETAIL = gql`
${MARKETPLACE_APP_DETAIL_FRAGMENT}
query FindMarketplaceAppDetail($universalIdentifier: String!) {
findMarketplaceAppDetail(universalIdentifier: $universalIdentifier) {
...MarketplaceAppDetailFields
}
}
`;
@@ -1,12 +0,0 @@
import gql from 'graphql-tag';
import { MARKETPLACE_APP_FRAGMENT } from '@/marketplace/graphql/fragments/marketplaceAppFragment';
export const FIND_ONE_MARKETPLACE_APP = gql`
${MARKETPLACE_APP_FRAGMENT}
query FindOneMarketplaceApp($universalIdentifier: String!) {
findOneMarketplaceApp(universalIdentifier: $universalIdentifier) {
...MarketplaceAppFields
}
}
`;
@@ -1,42 +1,11 @@
import { useQuery } from '@apollo/client/react';
import {
type MarketplaceApp,
FindManyMarketplaceAppsDocument,
} from '~/generated-metadata/graphql';
export type MarketplaceAppWithContentCounts = MarketplaceApp & {
content: {
objects: number;
fields: number;
functions: number;
frontComponents: number;
};
};
import { FindManyMarketplaceAppsDocument } from '~/generated-metadata/graphql';
export const useMarketplaceApps = () => {
const { data, loading, error } = useQuery(FindManyMarketplaceAppsDocument);
const marketplaceApps: MarketplaceAppWithContentCounts[] =
data?.findManyMarketplaceApps.map((app) => {
const totalFieldsCount =
(app.objects ?? []).reduce(
(count, appObject) => count + appObject.fields.length,
0,
) + (app.fields ?? []).length;
return {
...app,
content: {
objects: (app.objects ?? []).length,
fields: totalFieldsCount,
functions: (app.logicFunctions ?? []).length,
frontComponents: (app.frontComponents ?? []).length,
},
};
}) ?? [];
return {
data: marketplaceApps,
data: data?.findManyMarketplaceApps ?? [],
isLoading: loading,
error,
};
@@ -5,17 +5,12 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
id
universalIdentifier
name
description
logoUrl
author
oAuthClientId
oAuthRedirectUris
oAuthScopes
sourceType
sourcePackage
latestAvailableVersion
websiteUrl
termsUrl
isListed
isFeatured
ownerWorkspaceId
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const GET_APPLICATION_SHARE_LINK = gql`
query GetApplicationShareLink($id: String!) {
getApplicationShareLink(id: $id)
}
`;
@@ -14,6 +14,7 @@ import {
IconSettings,
} from 'twenty-ui/display';
import { SettingsApplicationDetailSkeletonLoader } from '~/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader';
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -40,11 +41,8 @@ export const SettingsApplicationDetails = () => {
const application = data?.findOneApplication;
const applicationName = application?.name;
const title = !isDefined(application)
? t`Application details`
: applicationName;
const applicationName = application?.name ?? t`Application details`;
const applicationDescription = application?.description ?? undefined;
const settingsCustomTabFrontComponentId =
application?.settingsCustomTabFrontComponentId;
@@ -94,7 +92,12 @@ export const SettingsApplicationDetails = () => {
return (
<SubMenuTopBarContainer
title={title}
title={
<SettingsApplicationDetailTitle
displayName={applicationName}
description={applicationDescription}
/>
}
links={[
{
children: t`Workspace`,
@@ -104,7 +107,7 @@ export const SettingsApplicationDetails = () => {
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: `${title}` },
{ children: applicationName },
]}
>
<SettingsPageContainer>
@@ -1,3 +1,4 @@
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
@@ -9,21 +10,26 @@ import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
IconApps,
IconAlertTriangle,
IconBook,
IconBox,
IconCheck,
IconColumns,
IconCommand,
IconDownload,
IconEyeOff,
IconFileText,
IconGraph,
IconInfoCircle,
IconLayoutGrid,
IconLego,
IconLink,
IconListDetails,
IconLock,
IconMail,
IconSettings,
IconShield,
IconUpload,
IconWorld,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -33,89 +39,32 @@ import { useQuery } from '@apollo/client/react';
import {
PermissionFlagType,
FindOneApplicationByUniversalIdentifierDocument,
FindOneMarketplaceAppDocument,
FindMarketplaceAppDetailDocument,
ApplicationRegistrationSourceType,
} from '~/generated-metadata/graphql';
import { useMarketplaceApps } from '~/modules/marketplace/hooks/useMarketplaceApps';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { SettingsAvailableApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsAvailableApplicationDetailContentTab';
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab';
const AVAILABLE_APPLICATION_DETAIL_ID = 'available-application-detail';
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[4]};
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[4]};
`;
const StyledHeaderLeft = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[3]};
`;
const StyledLogo = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex-shrink: 0;
height: 48px;
justify-content: center;
overflow: hidden;
width: 48px;
`;
const StyledLogoImage = styled.img`
height: 32px;
object-fit: contain;
width: 32px;
`;
const StyledLogoPlaceholder = styled.div`
align-items: center;
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.xs};
color: ${themeCssVariables.font.color.inverted};
display: flex;
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.medium};
height: 32px;
justify-content: center;
width: 32px;
`;
const StyledHeaderInfo = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledAppName = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledAppDescription = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
`;
const StyledContentContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[8]};
gap: ${themeCssVariables.spacing[4]};
`;
const StyledMainContent = styled.div`
flex: 1;
min-width: 0;
overflow: hidden;
`;
const StyledSidebar = styled.div`
flex-shrink: 0;
width: 180px;
width: 140px;
`;
const StyledSidebarSection = styled.div`
@@ -177,7 +126,7 @@ const StyledScreenshotsContainer = styled.div`
display: flex;
height: 300px;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[4]};
margin-bottom: ${themeCssVariables.spacing[2]};
overflow: hidden;
`;
@@ -222,43 +171,12 @@ const StyledThumbnailImage = styled.img`
const StyledSectionTitle = styled.h2`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.lg};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin: 0 0 ${themeCssVariables.spacing[3]} 0;
`;
const StyledAboutText = styled.p`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
line-height: 1.6;
margin: 0 0 ${themeCssVariables.spacing[6]} 0;
white-space: pre-line;
`;
const StyledProvidersList = styled.ul`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
list-style-type: disc;
margin: 0;
padding-left: ${themeCssVariables.spacing[5]};
`;
const StyledProviderItem = styled.li`
margin-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledUnlistedBanner = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.secondary};
display: flex;
font-size: ${themeCssVariables.font.size.md};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[4]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
`;
const StyledAboutContainer = styled.div``;
export const SettingsAvailableApplicationDetails = () => {
const { availableApplicationId = '' } = useParams<{
@@ -267,12 +185,13 @@ export const SettingsAvailableApplicationDetails = () => {
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
const { data: marketplaceApps } = useMarketplaceApps();
const { install, isInstalling } = useInstallMarketplaceApp();
const canInstallMarketplaceApps = useHasPermissionFlag(
PermissionFlagType.MARKETPLACE_APPS,
);
const { data: installedAppData } = useQuery(
const { data: applicationData } = useQuery(
FindOneApplicationByUniversalIdentifierDocument,
{
variables: { universalIdentifier: availableApplicationId },
@@ -280,47 +199,177 @@ export const SettingsAvailableApplicationDetails = () => {
},
);
const listedApp = marketplaceApps?.find(
(app) => app.id === availableApplicationId,
);
const { data: singleAppData } = useQuery(FindOneMarketplaceAppDocument, {
const { data: detailData } = useQuery(FindMarketplaceAppDetailDocument, {
variables: { universalIdentifier: availableApplicationId },
skip: isDefined(listedApp) || !availableApplicationId,
skip: !availableApplicationId,
});
const singleApp = singleAppData?.findOneMarketplaceApp;
const application = applicationData?.findOneApplication;
const application = isDefined(listedApp)
? listedApp
: isDefined(singleApp)
? {
...singleApp,
content: {
objects: (singleApp.objects ?? []).length,
fields:
(singleApp.objects ?? []).reduce(
(count, appObject) => count + appObject.fields.length,
0,
) + (singleApp.fields ?? []).length,
functions: (singleApp.logicFunctions ?? []).length,
frontComponents: (singleApp.frontComponents ?? []).length,
},
}
: undefined;
const detail = detailData?.findMarketplaceAppDetail;
const manifest = detail?.manifest as Manifest | undefined;
const app = manifest?.application;
const isUnlisted = !isDefined(listedApp) && isDefined(application);
const displayName = app?.displayName ?? detail?.name ?? '';
const description = app?.description ?? '';
const screenshots = app?.screenshots ?? [];
const aboutDescription = app?.aboutDescription;
const isAlreadyInstalled = isDefined(installedAppData?.findOneApplication);
const currentVersion = application?.version;
const latestAvailableVersion = detail?.latestAvailableVersion;
const sourceType = detail?.sourceType;
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
const registrationId = detail?.id;
const isUnlisted = isDefined(detail) && !detail.isListed;
const installedApp = applicationData?.findOneApplication;
const isAlreadyInstalled = isDefined(installedApp);
const hasScreenshots = screenshots.length > 0;
const defaultRole = manifest?.roles?.find(
(r) => r.universalIdentifier === app?.defaultRoleUniversalIdentifier,
);
const handleInstall = async () => {
if (isDefined(application)) {
if (isDefined(detail)) {
await install({
universalIdentifier: application.id,
universalIdentifier: detail.universalIdentifier,
});
}
};
const hasUpdate =
isNpmApp &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
const { upgrade, isUpgrading } = useUpgradeApplication();
const handleUpgrade = async () => {
if (!isDefined(registrationId) || !isDefined(latestAvailableVersion)) {
return;
}
await upgrade({
appRegistrationId: registrationId,
targetVersion: latestAvailableVersion,
});
};
const getActionButton = () => {
if (!canInstallMarketplaceApps) {
return null;
}
if (!isAlreadyInstalled) {
return (
<StyledSidebarSection>
<Button
Icon={IconDownload}
title={isInstalling ? t`Installing...` : t`Install`}
variant={'primary'}
accent={'blue'}
onClick={handleInstall}
disabled={isInstalling}
/>
</StyledSidebarSection>
);
}
if (hasUpdate && isDefined(registrationId)) {
return (
<StyledSidebarSection>
<Button
Icon={IconUpload}
title={
isUpgrading
? t`Upgrading...`
: t`Upgrade to ${latestAvailableVersion}`
}
variant={'secondary'}
accent={'blue'}
onClick={handleUpgrade}
disabled={isUpgrading}
/>
</StyledSidebarSection>
);
}
return (
<StyledSidebarSection>
<Button
Icon={IconCheck}
title={t`Installed`}
variant={'secondary'}
accent={'default'}
disabled={isAlreadyInstalled}
/>
</StyledSidebarSection>
);
};
const contentEntries = [
{
icon: IconBox,
count: (manifest?.objects ?? []).length,
one: t`object`,
many: t`objects`,
},
{
icon: IconListDetails,
count: (manifest?.fields ?? []).length,
one: t`field`,
many: t`fields`,
},
{
icon: IconCommand,
count: (manifest?.logicFunctions ?? []).length,
one: t`logic function`,
many: t`logic functions`,
},
{
icon: IconGraph,
count: (manifest?.frontComponents ?? []).filter(
(fc) =>
!isDefined(fc.command) &&
fc.universalIdentifier !==
manifest?.application
.settingsCustomTabFrontComponentUniversalIdentifier,
).length,
one: t`widget`,
many: t`widgets`,
},
{
icon: IconCommand,
count: (manifest?.frontComponents ?? []).filter(
(fc) => isDefined(fc.command) && !fc.isHeadless,
).length,
one: t`command`,
many: t`commands`,
},
{
icon: IconShield,
count: (manifest?.roles ?? []).filter(
(role) =>
role.universalIdentifier !==
manifest?.application.defaultRoleUniversalIdentifier,
).length,
one: t`role`,
many: t`roles`,
},
{
icon: IconBook,
count: (manifest?.skills ?? []).length,
one: t`skill`,
many: t`skills`,
},
{
icon: IconLego,
count: (manifest?.agents ?? []).length,
one: t`agent`,
many: t`agents`,
},
].filter((entry) => entry.count > 0);
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
AVAILABLE_APPLICATION_DETAIL_ID,
@@ -333,123 +382,145 @@ export const SettingsAvailableApplicationDetails = () => {
{ id: 'settings', title: t`Settings`, Icon: IconSettings },
];
const getInitials = (name: string) => {
return name.charAt(0).toUpperCase();
};
const hasScreenshots =
application?.screenshots && application.screenshots.length > 0;
const renderActiveTabContent = () => {
if (!application) return null;
if (!detail) return null;
switch (activeTabId) {
case 'about':
return (
<>
{hasScreenshots && (
<>
<StyledAboutContainer>
<StyledScreenshotsContainer>
<StyledScreenshotImage
src={application.screenshots[selectedScreenshotIndex]}
alt={`${application.name} screenshot ${selectedScreenshotIndex + 1}`}
src={screenshots[selectedScreenshotIndex]}
alt={`${displayName} screenshot ${selectedScreenshotIndex + 1}`}
/>
</StyledScreenshotsContainer>
<StyledScreenshotThumbnails>
{application.screenshots
.slice(0, 6)
.map((screenshot, index) => (
<StyledThumbnail
key={index}
isSelected={index === selectedScreenshotIndex}
onClick={() => setSelectedScreenshotIndex(index)}
>
<StyledThumbnailImage
src={screenshot}
alt={`${application.name} thumbnail ${index + 1}`}
/>
</StyledThumbnail>
))}
{screenshots.slice(0, 6).map((screenshot, index) => (
<StyledThumbnail
key={index}
isSelected={index === selectedScreenshotIndex}
onClick={() => setSelectedScreenshotIndex(index)}
>
<StyledThumbnailImage
src={screenshot}
alt={`${displayName} thumbnail ${index + 1}`}
/>
</StyledThumbnail>
))}
</StyledScreenshotThumbnails>
</>
</StyledAboutContainer>
)}
<StyledContentContainer>
<StyledMainContent>
<Section>
<StyledSectionTitle>{t`About`}</StyledSectionTitle>
<StyledAboutText>
{application.aboutDescription}
</StyledAboutText>
<StyledSectionTitle>{t`Providers`}</StyledSectionTitle>
<StyledProvidersList>
{application.providers.map((provider) => (
<StyledProviderItem key={provider}>
{provider}
</StyledProviderItem>
))}
</StyledProvidersList>
<LazyMarkdownRenderer
text={
aboutDescription
? aboutDescription
: t`No description available for this application`
}
/>
</Section>
</StyledMainContent>
<StyledSidebar>
{getActionButton()}
<StyledSidebarSection>
<StyledSidebarLabel>{t`Created by`}</StyledSidebarLabel>
<StyledSidebarValue>{application.author}</StyledSidebarValue>
</StyledSidebarSection>
<StyledSidebarSection>
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
<StyledSidebarValue>
{application.category}
{app?.author ?? 'Unknown'}
</StyledSidebarValue>
</StyledSidebarSection>
<StyledSidebarSection>
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
<StyledContentItem>
<IconLayoutGrid size={16} />
{application.content.objects} {t`objects`}
</StyledContentItem>
<StyledContentItem>
<IconColumns size={16} />
{application.content.fields} {t`fields`}
</StyledContentItem>
<StyledContentItem>
<IconApps size={16} />
{application.content.frontComponents} {t`front components`}
</StyledContentItem>
<StyledContentItem>
<IconCommand size={16} />
{application.content.functions} {t`functions`}
</StyledContentItem>
</StyledSidebarSection>
{app?.category && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
<StyledSidebarValue>{app.category}</StyledSidebarValue>
</StyledSidebarSection>
)}
{contentEntries.length > 0 && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
{contentEntries.map((entry) => (
<StyledContentItem key={entry.one}>
<entry.icon size={16} />
{entry.count}{' '}
{entry.count === 1 ? entry.one : entry.many}
</StyledContentItem>
))}
</StyledSidebarSection>
)}
{isAlreadyInstalled && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Current`}</StyledSidebarLabel>
<StyledSidebarValue>
{installedApp?.version ?? t`Unknown`}
</StyledSidebarValue>
</StyledSidebarSection>
)}
<StyledSidebarSection>
<StyledSidebarLabel>{t`Latest`}</StyledSidebarLabel>
<StyledSidebarValue>{application.version}</StyledSidebarValue>
<StyledSidebarValue>
{detail.latestAvailableVersion ?? '0.0.0'}
</StyledSidebarValue>
</StyledSidebarSection>
<StyledSidebarSection>
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
<StyledLink
href={application.websiteUrl ?? undefined}
target="_blank"
rel="noopener noreferrer"
>
<IconWorld size={16} />
{t`Website`}
</StyledLink>
<StyledLink
href={application.termsUrl ?? undefined}
target="_blank"
rel="noopener noreferrer"
>
<IconFileText size={16} />
{t`Terms / Privacy`}
</StyledLink>
</StyledSidebarSection>
{(app?.websiteUrl ||
app?.termsUrl ||
app?.emailSupport ||
app?.issueReportUrl) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
{app?.websiteUrl && (
<StyledLink
href={app.websiteUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconWorld size={16} />
{t`Website`}
</StyledLink>
)}
{app?.termsUrl && (
<StyledLink
href={app.termsUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconLink size={16} />
{t`Terms / Privacy`}
</StyledLink>
)}
{app?.emailSupport && (
<StyledLink
href={`mailto:${app.emailSupport}`}
target="_blank"
rel="noopener noreferrer"
>
<IconMail size={16} />
{t`Email support`}
</StyledLink>
)}
{app?.issueReportUrl && (
<StyledLink
href={app.issueReportUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconAlertTriangle size={16} />
{t`Report and issue`}
</StyledLink>
)}
</StyledSidebarSection>
)}
</StyledSidebar>
</StyledContentContainer>
</>
@@ -457,30 +528,32 @@ export const SettingsAvailableApplicationDetails = () => {
case 'content':
return (
<SettingsAvailableApplicationDetailContentTab
application={application}
applicationId={detail.universalIdentifier}
content={manifest}
/>
);
case 'permissions':
return (
<SettingsApplicationPermissionsTab
marketplaceAppDefaultRole={application.defaultRole}
marketplaceAppObjects={application.objects}
marketplaceAppDefaultRole={defaultRole}
marketplaceAppObjects={manifest?.objects}
/>
);
case 'settings':
return <div>{t`Settings tab`}</div>;
return (
<SettingsApplicationDetailSettingsTab application={application} />
);
default:
return null;
}
};
if (!application) {
if (!detail) {
return null;
}
return (
<SubMenuTopBarContainer
title={application.name}
links={[
{
children: t`Workspace`,
@@ -490,55 +563,18 @@ export const SettingsAvailableApplicationDetails = () => {
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: application.name },
{ children: displayName },
]}
title={
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
logoUrl={app?.logoUrl}
isUnlisted={isUnlisted}
/>
}
>
<SettingsPageContainer>
{isUnlisted && (
<StyledUnlistedBanner>
<IconEyeOff size={16} />
{t`This application is not listed on the marketplace. It was shared via a direct link.`}
</StyledUnlistedBanner>
)}
<StyledHeader>
<StyledHeaderLeft>
<StyledLogo>
{application.logo ? (
<StyledLogoImage
src={application.logo}
alt={application.name}
/>
) : (
<StyledLogoPlaceholder>
{getInitials(application.name)}
</StyledLogoPlaceholder>
)}
</StyledLogo>
<StyledHeaderInfo>
<StyledAppName>{application.name}</StyledAppName>
<StyledAppDescription>
{application.description}
</StyledAppDescription>
</StyledHeaderInfo>
</StyledHeaderLeft>
{canInstallMarketplaceApps && (
<Button
Icon={isAlreadyInstalled ? IconCheck : IconDownload}
title={
isAlreadyInstalled
? t`Installed`
: isInstalling
? t`Installing...`
: t`Install`
}
variant={isAlreadyInstalled ? 'secondary' : 'primary'}
accent={isAlreadyInstalled ? 'default' : 'blue'}
onClick={handleInstall}
disabled={isAlreadyInstalled || isInstalling}
/>
)}
</StyledHeader>
<TabList
tabs={tabs}
componentInstanceId={AVAILABLE_APPLICATION_DETAIL_ID}
@@ -0,0 +1,131 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconEyeOff } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
type SettingsApplicationDetailTitleProps = {
displayName: string;
description?: string;
logoUrl?: string;
isUnlisted?: boolean;
};
const StyledTitleContainer = styled.div`
margin-bottom: ${themeCssVariables.spacing[4]};
width: ${() => {
return OBJECT_SETTINGS_WIDTH + 'px';
}};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[4]};
justify-content: space-between;
`;
const StyledHeaderLeft = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledHeaderTop = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[3]};
`;
const StyledLogo = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex-shrink: 0;
height: 24px;
justify-content: center;
overflow: hidden;
width: 24px;
`;
const StyledLogoImage = styled.img`
height: 32px;
object-fit: contain;
width: 32px;
`;
const StyledLogoPlaceholder = styled.div`
align-items: center;
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.xs};
color: ${themeCssVariables.font.color.inverted};
display: flex;
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.medium};
height: 32px;
justify-content: center;
width: 32px;
`;
const StyledAppName = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledAppDescription = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledUnlistedBanner = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.secondary};
display: flex;
font-size: ${themeCssVariables.font.size.md};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[4]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
`;
export const SettingsApplicationDetailTitle = ({
displayName,
description,
logoUrl,
isUnlisted = false,
}: SettingsApplicationDetailTitleProps) => {
return (
<StyledTitleContainer>
{isUnlisted && (
<StyledUnlistedBanner>
<IconEyeOff size={16} />
{t`This application is not listed on the marketplace. It was shared via a direct link.`}
</StyledUnlistedBanner>
)}
<StyledHeader>
<StyledHeaderLeft>
<StyledHeaderTop>
<StyledLogo>
{logoUrl ? (
<StyledLogoImage src={logoUrl} alt={displayName} />
) : (
<StyledLogoPlaceholder>
{displayName.charAt(0).toUpperCase()}
</StyledLogoPlaceholder>
)}
</StyledLogo>
<StyledAppName>{displayName}</StyledAppName>
</StyledHeaderTop>
{description && (
<StyledAppDescription>{description}</StyledAppDescription>
)}
</StyledHeaderLeft>
</StyledHeader>
</StyledTitleContainer>
);
};
@@ -1,3 +1,5 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
@@ -11,6 +13,12 @@ import {
} from '~/generated-metadata/graphql';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
export const SettingsApplicationVersionContainer = ({
application,
latestAvailableVersion,
@@ -81,7 +89,7 @@ export const SettingsApplicationVersionContainer = ({
];
return (
<>
<StyledContainer>
<SettingsAdminTableCard
rounded
items={versionItems}
@@ -101,6 +109,6 @@ export const SettingsApplicationVersionContainer = ({
disabled={isUpgrading}
/>
)}
</>
</StyledContainer>
);
};
@@ -71,31 +71,6 @@ export const SettingsApplicationDetailAboutTab = ({
return (
<>
<Section>
<H2Title title={t`Name`} description={t`Name of the application`} />
<SettingsTextInput
instanceId={`application-name-${id}`}
value={name}
disabled
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Description`}
description={t`Description of the application`}
/>
<SettingsTextInput
instanceId={`application-description-${id}`}
value={description ?? undefined}
disabled
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Version`}
description={t`Version of the application`}
/>
<SettingsApplicationVersionContainer
application={application}
latestAvailableVersion={latestAvailableVersion}
@@ -29,12 +29,13 @@ export const SettingsApplicationDetailEnvironmentVariablesTable = ({
},
250,
);
const description =
editedEnvVariables.length > 0
? t`Set your application configuration variables`
: t`No variables to set for this application`;
return (
<Section>
<H2Title
title={t`Configuration`}
description={t`Set your application configuration variables`}
/>
<H2Title title={t`Configuration`} description={description} />
<StyledContainer>
{editedEnvVariables.map((editedEnvVariable) => (
<TextInput
@@ -1,37 +1,33 @@
import { isDefined } from 'twenty-shared/utils';
import type { Application } from '~/generated-metadata/graphql';
import { type Application } from '~/generated-metadata/graphql';
import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable';
import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable';
export const SettingsApplicationDetailSettingsTab = ({
application,
}: {
application?: Omit<Application, 'objects'> & {
objects: { id: string }[];
};
application?: Pick<
Application,
'applicationVariables' | 'id' | 'universalIdentifier' | 'canBeUninstalled'
>;
}) => {
const { updateOneApplicationVariable } = useUpdateOneApplicationVariable();
if (!isDefined(application)) {
return null;
}
const envVariables = [...(application.applicationVariables ?? [])].sort(
const envVariables = [...(application?.applicationVariables ?? [])].sort(
(a, b) => a.key.localeCompare(b.key),
);
return (
<>
<SettingsApplicationDetailEnvironmentVariablesTable
envVariables={envVariables}
onUpdate={({ key, value }) =>
updateOneApplicationVariable({
key,
value,
applicationId: application.id,
})
}
/>
</>
<SettingsApplicationDetailEnvironmentVariablesTable
envVariables={envVariables}
onUpdate={({ key, value }) =>
application?.id
? updateOneApplicationVariable({
key,
value,
applicationId: application.id,
})
: null
}
/>
);
};
@@ -9,14 +9,16 @@ import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAt
import { t } from '@lingui/core/macro';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
type ObjectFieldManifest,
type ObjectManifest,
type RoleManifest,
} from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import {
FieldMetadataType,
type MarketplaceAppDefaultRole,
type MarketplaceAppField,
type MarketplaceAppObject,
type PermissionFlagType,
} from '~/generated-metadata/graphql';
@@ -24,12 +26,12 @@ import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applicatio
type SettingsApplicationPermissionsTabProps = {
defaultRoleId?: string | null;
marketplaceAppDefaultRole?: MarketplaceAppDefaultRole | null;
marketplaceAppObjects?: MarketplaceAppObject[];
marketplaceAppDefaultRole?: RoleManifest;
marketplaceAppObjects?: ObjectManifest[];
};
const resolvePermissionIds = (
defaultRole: MarketplaceAppDefaultRole,
defaultRole: RoleManifest,
objectMetadataItems: EnrichedObjectMetadataItem[],
): {
objectUniversalIdToIdMap: Record<string, string>;
@@ -40,10 +42,10 @@ const resolvePermissionIds = (
const allObjectUniversalIds = new Set<string>();
for (const permission of defaultRole.objectPermissions) {
for (const permission of defaultRole.objectPermissions ?? []) {
allObjectUniversalIds.add(permission.objectUniversalIdentifier);
}
for (const permission of defaultRole.fieldPermissions) {
for (const permission of defaultRole.fieldPermissions ?? []) {
allObjectUniversalIds.add(permission.objectUniversalIdentifier);
}
@@ -61,7 +63,7 @@ const resolvePermissionIds = (
}
}
for (const permission of defaultRole.fieldPermissions) {
for (const permission of defaultRole.fieldPermissions ?? []) {
const objectName = findObjectNameByUniversalIdentifier(
permission.objectUniversalIdentifier,
);
@@ -100,22 +102,23 @@ const resolvePermissionIds = (
};
const buildSyntheticRole = (
defaultRole: MarketplaceAppDefaultRole,
defaultRole: RoleManifest,
objectUniversalIdToIdMap: Record<string, string>,
fieldUniversalIdToIdMap: Record<string, string>,
): RoleWithPartialMembers => ({
__typename: 'Role',
id: defaultRole.id,
id: defaultRole.universalIdentifier,
label: defaultRole.label,
description: defaultRole.description ?? '',
icon: '',
isEditable: false,
canReadAllObjectRecords: defaultRole.canReadAllObjectRecords,
canUpdateAllObjectRecords: defaultRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: defaultRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: defaultRole.canDestroyAllObjectRecords,
canUpdateAllSettings: defaultRole.canUpdateAllSettings,
canAccessAllTools: defaultRole.canAccessAllTools,
canReadAllObjectRecords: defaultRole.canReadAllObjectRecords ?? false,
canUpdateAllObjectRecords: defaultRole.canUpdateAllObjectRecords ?? false,
canSoftDeleteAllObjectRecords:
defaultRole.canSoftDeleteAllObjectRecords ?? false,
canDestroyAllObjectRecords: defaultRole.canDestroyAllObjectRecords ?? false,
canUpdateAllSettings: defaultRole.canUpdateAllSettings ?? false,
canAccessAllTools: defaultRole.canAccessAllTools ?? false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: false,
@@ -124,20 +127,22 @@ const buildSyntheticRole = (
apiKeys: [],
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
objectPermissions: defaultRole.objectPermissions.map((permission) => ({
__typename: 'ObjectPermission' as const,
objectMetadataId:
objectUniversalIdToIdMap[permission.objectUniversalIdentifier] ??
permission.objectUniversalIdentifier,
canReadObjectRecords: permission.canReadObjectRecords,
canUpdateObjectRecords: permission.canUpdateObjectRecords,
canSoftDeleteObjectRecords: permission.canSoftDeleteObjectRecords,
canDestroyObjectRecords: permission.canDestroyObjectRecords,
})),
fieldPermissions: defaultRole.fieldPermissions.map((permission) => ({
objectPermissions: (defaultRole.objectPermissions ?? []).map(
(permission) => ({
__typename: 'ObjectPermission' as const,
objectMetadataId:
objectUniversalIdToIdMap[permission.objectUniversalIdentifier] ??
permission.objectUniversalIdentifier,
canReadObjectRecords: permission.canReadObjectRecords,
canUpdateObjectRecords: permission.canUpdateObjectRecords,
canSoftDeleteObjectRecords: permission.canSoftDeleteObjectRecords,
canDestroyObjectRecords: permission.canDestroyObjectRecords,
}),
),
fieldPermissions: (defaultRole.fieldPermissions ?? []).map((permission) => ({
__typename: 'FieldPermission' as const,
id: uuidv4(),
roleId: defaultRole.id,
roleId: defaultRole.universalIdentifier,
objectMetadataId:
objectUniversalIdToIdMap[permission.objectUniversalIdentifier] ??
permission.objectUniversalIdentifier,
@@ -147,16 +152,16 @@ const buildSyntheticRole = (
canReadFieldValue: permission.canReadFieldValue,
canUpdateFieldValue: permission.canUpdateFieldValue,
})),
permissionFlags: defaultRole.permissionFlags.map((flag) => ({
permissionFlags: (defaultRole.permissionFlags ?? []).map((flag) => ({
__typename: 'PermissionFlag' as const,
id: uuidv4(),
roleId: defaultRole.id,
roleId: defaultRole.universalIdentifier,
flag: flag as PermissionFlagType,
})),
});
const buildFieldMetadataItemFromMarketplaceField = (
field: MarketplaceAppField,
field: ObjectFieldManifest,
): FieldMetadataItem => {
const now = new Date().toISOString();
@@ -184,20 +189,20 @@ const buildFieldMetadataItemFromMarketplaceField = (
};
const buildobjectMetadataItemsFromMarketplaceApp = (
defaultRole: MarketplaceAppDefaultRole,
defaultRole: RoleManifest,
objectUniversalIdToIdMap: Record<string, string>,
marketplaceAppObjects: MarketplaceAppObject[],
marketplaceAppObjects: ObjectManifest[],
): EnrichedObjectMetadataItem[] => {
const unresolvedUniversalIds = new Set<string>();
for (const permission of defaultRole.objectPermissions) {
for (const permission of defaultRole.objectPermissions ?? []) {
if (
!isDefined(objectUniversalIdToIdMap[permission.objectUniversalIdentifier])
) {
unresolvedUniversalIds.add(permission.objectUniversalIdentifier);
}
}
for (const permission of defaultRole.fieldPermissions) {
for (const permission of defaultRole.fieldPermissions ?? []) {
if (
!isDefined(objectUniversalIdToIdMap[permission.objectUniversalIdentifier])
) {
@@ -221,7 +226,9 @@ const buildobjectMetadataItemsFromMarketplaceApp = (
buildFieldMetadataItemFromMarketplaceField,
);
const objectFieldPermissions = defaultRole.fieldPermissions.filter(
const objectFieldPermissions = (
defaultRole.fieldPermissions ?? []
).filter(
(permission) => permission.objectUniversalIdentifier === universalId,
);
@@ -277,8 +284,8 @@ const MarketplaceRoleEffect = ({
marketplaceAppObjects,
onObjectMetadataItemsFromMarketplaceApp,
}: {
defaultRole: MarketplaceAppDefaultRole;
marketplaceAppObjects: MarketplaceAppObject[];
defaultRole: RoleManifest;
marketplaceAppObjects: ObjectManifest[];
onObjectMetadataItemsFromMarketplaceApp: (
items: EnrichedObjectMetadataItem[],
) => void;
@@ -286,7 +293,7 @@ const MarketplaceRoleEffect = ({
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const setSettingsDraftRole = useSetAtomFamilyState(
settingsDraftRoleFamilyState,
defaultRole.id,
defaultRole.universalIdentifier,
);
const { resolvedRole, objectMetadataItemsFromMarketplaceApp } =
@@ -329,8 +336,8 @@ const MarketplaceAppPermissions = ({
defaultRole,
marketplaceAppObjects,
}: {
defaultRole: MarketplaceAppDefaultRole;
marketplaceAppObjects: MarketplaceAppObject[];
defaultRole: RoleManifest;
marketplaceAppObjects: ObjectManifest[];
}) => {
const [
objectMetadataItemsFromMarketplaceApp,
@@ -353,7 +360,7 @@ const MarketplaceAppPermissions = ({
}
/>
<SettingsRolePermissions
roleId={defaultRole.id}
roleId={defaultRole.universalIdentifier}
isEditable={false}
objectMetadataItemsFromMarketplaceApp={
objectMetadataItemsFromMarketplaceApp
@@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react/macro';
import {
H2Title,
IconChartBar,
IconCopy,
IconDownload,
IconExternalLink,
IconTag,
@@ -17,8 +18,10 @@ import { getSettingsPath } from 'twenty-shared/utils';
import {
ApplicationRegistrationSourceType,
FindApplicationRegistrationStatsDocument,
GetApplicationShareLinkDocument,
} from '~/generated-metadata/graphql';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { type ApplicationRegistrationData } from '~/pages/settings/applications/tabs/types/ApplicationRegistrationData';
const StyledButtonGroup = styled.div`
@@ -36,9 +39,21 @@ export const SettingsApplicationRegistrationDistributionTab = ({
const applicationRegistrationId = registration.id;
const { copyToClipboard } = useCopyToClipboard();
const isNpmSource =
registration.sourceType === ApplicationRegistrationSourceType.NPM;
const isTarballSource =
registration.sourceType === ApplicationRegistrationSourceType.TARBALL;
const { data: shareLinkData } = useQuery(GetApplicationShareLinkDocument, {
variables: { id: applicationRegistrationId },
skip: !isTarballSource || !applicationRegistrationId,
});
const shareLink = shareLinkData?.getApplicationShareLink;
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
@@ -111,6 +126,34 @@ export const SettingsApplicationRegistrationDistributionTab = ({
</Section>
)}
{isTarballSource && (
<Section>
<H2Title
title={t`Share`}
description={t`Share this link with other workspaces on this server to let them install this application.`}
/>
<StyledButtonGroup>
<Button
Icon={IconCopy}
title={t`Copy share link`}
variant="secondary"
disabled={!shareLink}
onClick={() => {
if (shareLink) {
copyToClipboard(shareLink, t`Share link copied to clipboard`);
}
}}
/>
<Button
Icon={IconExternalLink}
title={t`View marketplace page`}
variant="secondary"
to={marketplacePageUrl}
/>
</StyledButtonGroup>
</Section>
)}
{hasStats && (
<Section>
<H2Title
@@ -332,15 +332,6 @@ export const SettingsApplicationRegistrationGeneralTab = ({
label: t`Name`,
value: registration.name,
},
...(isNonEmptyString(registration.description)
? [
{
Icon: IconTextCaption,
label: t`Description`,
value: registration.description,
},
]
: []),
{
Icon: IconWorld,
label: t`Universal ID`,
@@ -2,9 +2,9 @@ import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMeta
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { type MarketplaceApp } from '~/generated-metadata/graphql';
import {
type ApplicationDataTableFieldItem,
type ApplicationDataTableRow,
@@ -14,17 +14,22 @@ import { SettingsApplicationNameDescriptionTable } from '~/pages/settings/applic
import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier';
export const SettingsAvailableApplicationDetailContentTab = ({
application,
applicationId,
content,
}: {
application: MarketplaceApp;
applicationId: string;
content?: Manifest;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const { objects, fields, logicFunctions, frontComponents } = application;
const objects = content?.objects ?? [];
const fields = content?.fields ?? [];
const logicFunctions = content?.logicFunctions ?? [];
const frontComponents = content?.frontComponents ?? [];
const objectRows = useMemo(
(): ApplicationDataTableRow[] =>
(objects ?? []).map((appObject) => ({
objects.map((appObject) => ({
key: appObject.nameSingular,
labelPlural: appObject.labelPlural,
icon: appObject.icon ?? undefined,
@@ -37,13 +42,13 @@ export const SettingsAvailableApplicationDetailContentTab = ({
type: field.type,
}),
),
tagItem: { applicationId: application.id },
tagItem: { applicationId },
})),
[objects, application.id],
[objects, applicationId],
);
const fieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
if (!isDefined(fields) || fields.length === 0) {
if (fields.length === 0) {
return [];
}
@@ -56,11 +61,9 @@ export const SettingsAvailableApplicationDetailContentTab = ({
>();
for (const field of fields) {
if (!isDefined(field.objectUniversalIdentifier)) {
continue;
}
const objectUid = field.objectUniversalIdentifier;
const existing = groupMap.get(field.objectUniversalIdentifier);
const existing = groupMap.get(objectUid);
const fieldItem: ApplicationDataTableFieldItem = {
key: field.name,
label: field.label,
@@ -71,17 +74,16 @@ export const SettingsAvailableApplicationDetailContentTab = ({
if (isDefined(existing)) {
existing.fieldItems.push(fieldItem);
} else {
groupMap.set(field.objectUniversalIdentifier, {
objectUniversalIdentifier: field.objectUniversalIdentifier,
groupMap.set(objectUid, {
objectUniversalIdentifier: objectUid,
fieldItems: [fieldItem],
});
}
}
return Array.from(groupMap.values()).map((group) => {
const appObject = objects?.find(
(appObj) =>
appObj.universalIdentifier === group.objectUniversalIdentifier,
const appObject = objects.find(
(obj) => obj.universalIdentifier === group.objectUniversalIdentifier,
);
if (isDefined(appObject)) {
@@ -91,7 +93,7 @@ export const SettingsAvailableApplicationDetailContentTab = ({
icon: appObject.icon ?? undefined,
fieldsCount: group.fieldItems.length,
fields: group.fieldItems,
tagItem: { applicationId: application.id },
tagItem: { applicationId },
};
}
@@ -123,7 +125,14 @@ export const SettingsAvailableApplicationDetailContentTab = ({
tagItem: {},
};
});
}, [fields, objectMetadataItems, objects, application.id]);
}, [fields, objectMetadataItems, objects, applicationId]);
const roles = content?.roles ?? [];
const skills = content?.skills ?? [];
const agents = content?.agents ?? [];
const views = content?.views ?? [];
const navigationMenuItems = content?.navigationMenuItems ?? [];
const pageLayouts = content?.pageLayouts ?? [];
return (
<>
@@ -132,16 +141,73 @@ export const SettingsAvailableApplicationDetailContentTab = ({
fieldGroupRows={fieldGroupRows}
/>
<SettingsApplicationNameDescriptionTable
title={t`Functions`}
title={t`Logic functions`}
description={t`Logic functions provided by this app`}
sectionTitle={t`Logic functions`}
items={logicFunctions}
items={logicFunctions.map((lf) => ({
name: lf.name ?? lf.universalIdentifier,
description: lf.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Front components`}
description={t`UI components provided by this app`}
sectionTitle={t`Front components`}
items={frontComponents}
items={frontComponents.map((fc) => ({
name: fc.name ?? fc.universalIdentifier,
description: fc.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Roles`}
description={t`Roles defined by this app`}
sectionTitle={t`Roles`}
items={roles.map((role) => ({
name: role.label,
description: role.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Skills`}
description={t`Skills provided by this app`}
sectionTitle={t`Skills`}
items={skills.map((skill) => ({
name: skill.label ?? skill.name,
description: skill.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Agents`}
description={t`Agents provided by this app`}
sectionTitle={t`Agents`}
items={agents.map((agent) => ({
name: agent.label ?? agent.name,
description: agent.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Views`}
description={t`Views created by this app`}
sectionTitle={t`Views`}
items={views.map((view) => ({
name: view.name,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Navigation menu items`}
description={t`Navigation items added by this app`}
sectionTitle={t`Navigation items`}
items={navigationMenuItems.map((item) => ({
name: item.name ?? item.universalIdentifier,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Page layouts`}
description={t`Page layouts defined by this app`}
sectionTitle={t`Page layouts`}
items={pageLayouts.map((layout) => ({
name: layout.name,
}))}
/>
</>
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "0.8.0-canary.5",
"version": "0.8.0-canary.6",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/sdk/index.d.ts",
@@ -7,6 +7,7 @@ import { AppInstallCommand } from './install';
import { AppPublishCommand } from './publish';
import { AppTypecheckCommand } from './typecheck';
import { AppUninstallCommand } from './uninstall';
import { CatalogSyncCommand } from './catalog-sync';
import { DeployCommand } from './deploy';
import { LogicFunctionExecuteCommand } from './exec';
import { LogicFunctionLogsCommand } from './logs';
@@ -22,6 +23,7 @@ export const registerCommands = (program: Command): void => {
const publishCommand = new AppPublishCommand();
const typecheckCommand = new AppTypecheckCommand();
const uninstallCommand = new AppUninstallCommand();
const catalogSyncCommand = new CatalogSyncCommand();
const deployCommand = new DeployCommand();
const addCommand = new EntityAddCommand();
const logsCommand = new LogicFunctionLogsCommand();
@@ -80,6 +82,16 @@ export const registerCommands = (program: Command): void => {
});
});
program
.command('catalog-sync')
.description('Trigger marketplace catalog sync on the server')
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options) => {
await catalogSyncCommand.execute({
remote: options.remote,
});
});
program
.command('typecheck [appPath]')
.description('Run TypeScript type checking on the application')
@@ -0,0 +1,32 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
export type CatalogSyncCommandOptions = {
remote?: string;
};
export class CatalogSyncCommand {
async execute(options: CatalogSyncCommandOptions): Promise<void> {
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
console.log(chalk.blue('Syncing marketplace catalog...'));
const apiService = new ApiService();
const result = await apiService.syncMarketplaceCatalog();
if (!result.success) {
console.error(
chalk.red(
`Catalog sync failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`,
),
);
process.exit(1);
}
console.log(chalk.green('✓ Marketplace catalog synced successfully'));
}
}
+44 -22
View File
@@ -71,6 +71,7 @@ export const registerRemoteCommands = (program: Command): void => {
.option('--as <name>', 'Name for this remote')
.option('--token <token>', 'API key for non-interactive auth')
.option('--url <url>', 'Server URL (alternative to positional arg)')
.option('--local', 'Connect to a local Twenty server (auto-detect)')
.action(
async (
nameOrUrl: string | undefined,
@@ -78,6 +79,7 @@ export const registerRemoteCommands = (program: Command): void => {
as?: string;
token?: string;
url?: string;
local?: boolean;
},
) => {
const configService = new ConfigService();
@@ -96,30 +98,50 @@ export const registerRemoteCommands = (program: Command): void => {
return;
}
// Resolve the URL — from args, flags, or interactive prompt
const apiUrl =
nameOrUrl ??
options.url ??
(options.token
? ((await detectLocalServer()) ?? 'http://localhost:2020')
: (
await inquirer.prompt<{ apiUrl: string }>([
{
type: 'input',
name: 'apiUrl',
message: 'Twenty server URL:',
validate: (input: string) => {
try {
new URL(input);
// Resolve the URL — from args, flags, auto-detect, or interactive prompt
let apiUrl = nameOrUrl ?? options.url;
return true;
} catch {
return 'Please enter a valid URL';
}
},
if (!apiUrl) {
const detectedUrl = await detectLocalServer();
if (options.local) {
if (!detectedUrl) {
console.error(
chalk.red(
'No local Twenty server found on ports 2020 or 3000.\n' +
'Start one with: yarn twenty server start',
),
);
process.exit(1);
}
apiUrl = detectedUrl;
} else if (detectedUrl) {
console.log(chalk.gray(`Found local server at ${detectedUrl}`));
apiUrl = detectedUrl;
} else if (options.token) {
apiUrl = 'http://localhost:2020';
} else {
apiUrl = (
await inquirer.prompt<{ apiUrl: string }>([
{
type: 'input',
name: 'apiUrl',
message: 'Twenty server URL:',
validate: (input: string) => {
try {
new URL(input);
return true;
} catch {
return 'Please enter a valid URL';
}
},
])
).apiUrl);
},
])
).apiUrl;
}
}
const name = options.as ?? deriveRemoteName(apiUrl);
@@ -42,8 +42,7 @@ const innerAppPublish = async (
success: false,
error: {
code: APP_ERROR_CODES.PUBLISH_FAILED,
message:
'npm publish failed. Make sure you are logged in (`npm login`) and the package name is available.',
message: `npm publish failed`,
},
};
}
@@ -66,6 +66,10 @@ export class ApiService {
return this.applicationApi.uninstallApplication(universalIdentifier);
}
syncMarketplaceCatalog(): Promise<ApiResponse<boolean>> {
return this.applicationApi.syncMarketplaceCatalog();
}
getSchema(options?: { authToken?: string }): Promise<ApiResponse<string>> {
return this.schemaApi.getSchema(options);
}
@@ -5,6 +5,44 @@ 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<
@@ -63,7 +101,6 @@ export class ApplicationApi {
async createApplicationRegistration(input: {
name: string;
description?: string;
universalIdentifier: string;
}): Promise<
ApiResponse<{
@@ -1,147 +0,0 @@
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
import { ApiService } from '@/cli/utilities/api/api-service';
import { type BuiltFileInfo } from '@/cli/utilities/build/common/build-application';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { type Manifest } from 'twenty-shared/application';
export type AppSyncOptions = {
appPath: string;
remote?: string;
};
const ensureApplicationRegistrationExists = async (
apiService: ApiService,
manifest: Manifest,
): Promise<CommandResult> => {
const universalIdentifier = manifest.application.universalIdentifier;
const findResult =
await apiService.findApplicationRegistrationByUniversalIdentifier(
universalIdentifier,
);
if (findResult.success && findResult.data) {
return { success: true, data: undefined };
}
const createResult = await apiService.createApplicationRegistration({
name: manifest.application.displayName,
description: manifest.application.description,
universalIdentifier,
});
if (!createResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to create application registration: ${serializeError(createResult.error)}`,
},
};
}
return { success: true, data: undefined };
};
const ensureDevelopmentApplicationExists = async (
apiService: ApiService,
manifest: Manifest,
): Promise<CommandResult> => {
const result = await apiService.createDevelopmentApplication({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
});
if (!result.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to create development application: ${serializeError(result.error)}`,
},
};
}
return { success: true, data: undefined };
};
export const synchronizeBuiltApplication = async ({
appPath,
manifest,
builtFileInfos,
}: {
appPath: string;
manifest: Manifest;
builtFileInfos: Map<string, BuiltFileInfo>;
}): Promise<CommandResult> => {
const apiService = new ApiService();
const universalIdentifier = manifest.application.universalIdentifier;
const registrationResult = await ensureApplicationRegistrationExists(
apiService,
manifest,
);
if (!registrationResult.success) {
return registrationResult;
}
const applicationResult = await ensureDevelopmentApplicationExists(
apiService,
manifest,
);
if (!applicationResult.success) {
return applicationResult;
}
const fileUploader = new FileUploader({
applicationUniversalIdentifier: universalIdentifier,
appPath,
});
const uploadResults = await Promise.all(
[...builtFileInfos.values()].map((fileInfo) =>
fileUploader.uploadFile({
builtPath: fileInfo.builtPath,
fileFolder: fileInfo.fileFolder,
}),
),
);
const failedUploads = uploadResults.filter((result) => !result.success);
if (failedUploads.length > 0) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to upload ${failedUploads.length} file(s).`,
},
};
}
const updatedManifest = manifestUpdateChecksums({
manifest,
builtFileInfos,
});
await writeManifestToOutput(appPath, updatedManifest);
const syncResult = await apiService.syncApplication(updatedManifest);
if (!syncResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Sync failed: ${serializeError(syncResult.error)}`,
},
};
}
return { success: true, data: undefined };
};
@@ -70,7 +70,6 @@ export class RegisterAppOrchestratorStep {
const createResult = await this.apiService.createApplicationRegistration({
name: input.manifest.application.displayName,
description: input.manifest.application.description,
universalIdentifier,
});
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameMarketplaceDisplayDataToManifest1774472400000
implements MigrationInterface
{
name = 'RenameMarketplaceDisplayDataToManifest1774472400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" RENAME COLUMN "marketplaceDisplayData" TO "manifest"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "description"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "logoUrl"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "author"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "websiteUrl"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "termsUrl"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "termsUrl" text`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "websiteUrl" text`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "author" text`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "logoUrl" text`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "description" text`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" RENAME COLUMN "manifest" TO "marketplaceDisplayData"`,
);
}
}
@@ -24,6 +24,7 @@ import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import {
@@ -36,6 +37,7 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
@@ -64,6 +66,7 @@ export class ApplicationDevelopmentResolver {
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly fileStorageService: FileStorageService,
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@@ -71,10 +74,8 @@ export class ApplicationDevelopmentResolver {
@Args() { universalIdentifier, name }: CreateDevelopmentApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<DevelopmentApplicationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
universalIdentifier,
workspaceId,
);
const applicationRegistrationId =
await this.findApplicationRegistrationId(universalIdentifier);
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier,
@@ -125,7 +126,6 @@ export class ApplicationDevelopmentResolver {
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
manifest.application.universalIdentifier,
workspaceId,
);
const application = await this.applicationService.findByUniversalIdentifier(
@@ -164,6 +164,7 @@ export class ApplicationDevelopmentResolver {
applicationRegistrationId,
manifest,
workspaceId,
application.id,
);
return {
@@ -216,7 +217,6 @@ export class ApplicationDevelopmentResolver {
private async findApplicationRegistrationId(
universalIdentifier: string,
workspaceId: string,
): Promise<string> {
const existingRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
@@ -230,55 +230,33 @@ export class ApplicationDevelopmentResolver {
);
}
const isOwner =
await this.applicationRegistrationService.isOwnedByWorkspace(
existingRegistration.id,
workspaceId,
);
if (!isOwner) {
throw new ApplicationException(
'Cannot sync application: registration is owned by another workspace',
ApplicationExceptionCode.FORBIDDEN,
);
}
return existingRegistration.id;
}
private async syncRegistrationMetadata(
applicationRegistrationId: string,
manifest: { application: ApplicationInput['manifest']['application'] },
manifest: ApplicationInput['manifest'],
workspaceId: string,
applicationId: string,
): Promise<void> {
const isOwner =
await this.applicationRegistrationService.isOwnedByWorkspace(
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
);
await this.applicationRegistrationService.updateFromManifest(
applicationRegistrationId,
manifestWithResolvedUrls,
);
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
workspaceId,
manifest.application.serverVariables,
);
if (isOwner) {
await this.applicationRegistrationService.update(
{
id: applicationRegistrationId,
update: {
name: manifest.application.displayName,
description: manifest.application.description,
logoUrl: manifest.application.logoUrl,
author: manifest.application.author,
websiteUrl: manifest.application.websiteUrl,
termsUrl: manifest.application.termsUrl,
},
},
workspaceId,
);
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
manifest.application.serverVariables,
);
}
}
}
}
@@ -1,32 +1,22 @@
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
export type CuratedAppEntry = {
universalIdentifier: string;
sourcePackage: string;
isFeatured: boolean;
name: string;
description: string;
author: string;
logoUrl?: string;
websiteUrl?: string;
termsUrl?: string;
richDisplayData: MarketplaceDisplayData;
latestAvailableVersion?: string;
};
const MOCK_ENRICHMENT_APP_ID = 'a1b2c3d4-0000-0000-0000-000000000001';
const MOCK_ENRICHMENT_JOB_ID = 'a1b2c3d4-0000-0000-0000-000000000100';
const COMPANY_UNIVERSAL_ID = '20202020-b374-4779-a561-80086cb2e17f';
const PERSON_UNIVERSAL_ID = '20202020-e674-48e5-a542-72570eee7213';
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
const ENCODED_MOCK_LOGO = `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`;
export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
{
universalIdentifier: MOCK_ENRICHMENT_APP_ID,
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000001',
sourcePackage: '@twentyhq/app-data-enrichment',
isFeatured: true,
name: 'Data Enrichment',
@@ -34,146 +24,6 @@ export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
author: 'Twenty',
logoUrl: ENCODED_MOCK_LOGO,
websiteUrl: 'https://twenty.com',
richDisplayData: {
icon: 'IconSparkles',
version: '1.0.0',
category: 'Data',
logo: ENCODED_MOCK_LOGO,
screenshots: [
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+1',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+2',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+3',
],
aboutDescription:
'Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.',
providers: ['Clearbit', 'Apollo', 'Hunter.io'],
objects: [
{
universalIdentifier: MOCK_ENRICHMENT_JOB_ID,
nameSingular: 'enrichmentJob',
namePlural: 'enrichmentJobs',
labelSingular: 'Enrichment Job',
labelPlural: 'Enrichment Jobs',
description: 'Tracks data enrichment requests and their status',
icon: 'IconSparkles',
fields: [
{
name: 'status',
type: 'SELECT',
label: 'Status',
description: 'Current status of the enrichment job',
icon: 'IconProgressCheck',
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000101',
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
},
{
name: 'provider',
type: 'TEXT',
label: 'Provider',
description: 'Enrichment provider used',
icon: 'IconCloud',
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000102',
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
},
],
},
],
fields: [
{
name: 'industry',
type: 'TEXT',
label: 'Industry',
description: 'Company industry from enrichment',
icon: 'IconBuildingFactory2',
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000201',
},
{
name: 'linkedInUrl',
type: 'LINKS',
label: 'LinkedIn URL',
description: 'LinkedIn profile URL from enrichment',
icon: 'IconBrandLinkedin',
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000203',
},
],
logicFunctions: [
{
name: 'enrich-on-create',
description:
'Automatically enriches new records when they are created',
timeoutSeconds: 30,
},
],
frontComponents: [],
defaultRole: {
id: 'a1b2c3d4-0000-0000-0000-000000000010',
label: 'Data Enrichment default role',
description: 'Default permissions for the Data Enrichment app',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canUpdateAllSettings: false,
canAccessAllTools: false,
objectPermissions: [
{
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlags: ['DATA_MODEL', 'API_KEYS_AND_WEBHOOKS'],
},
},
},
{
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
sourcePackage: '@twentyhq/hello-world',
isFeatured: false,
name: 'Hello World',
description: 'A simple hello world app to get started with Twenty apps.',
author: 'Twenty',
websiteUrl: 'https://twenty.com',
richDisplayData: {
icon: 'IconWorld',
version: '0.2.2',
category: 'Getting Started',
screenshots: [],
aboutDescription:
'A minimal example app that demonstrates the Twenty app framework. Creates a PostCard object and a logic function to generate new postcards. Great starting point for building your own apps.',
providers: [],
objects: [
{
universalIdentifier: 'e2c3d4f5-0000-0000-0000-000000000001',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post Card',
labelPlural: 'Post Cards',
description: 'A simple postcard object',
icon: 'IconMail',
fields: [],
},
],
fields: [],
logicFunctions: [
{
name: 'create-new-post-card',
description: 'Creates a new postcard record',
},
],
frontComponents: [],
},
latestAvailableVersion: '1.0.0',
},
];
@@ -0,0 +1,19 @@
import { Command, CommandRunner } from 'nest-commander';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service';
@Command({
name: 'marketplace:catalog-sync',
description: 'Sync the marketplace catalog into ApplicationRegistration',
})
export class MarketplaceCatalogSyncCommand extends CommandRunner {
constructor(
private readonly marketplaceCatalogSyncService: MarketplaceCatalogSyncService,
) {
super();
}
async run(): Promise<void> {
await this.marketplaceCatalogSyncService.syncCatalog();
}
}
@@ -0,0 +1,49 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { type Manifest } from 'twenty-shared/application';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
@ObjectType('MarketplaceAppDetail')
export class MarketplaceAppDetailDTO {
@IsString()
@IsNotEmpty()
@Field()
universalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
id: string;
@IsString()
@IsNotEmpty()
@Field()
name: string;
@Field(() => ApplicationRegistrationSourceType)
sourceType: ApplicationRegistrationSourceType;
@IsOptional()
@IsString()
@Field({ nullable: true })
sourcePackage?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
latestAvailableVersion?: string;
@IsBoolean()
@Field(() => Boolean)
isListed: boolean;
@IsBoolean()
@Field(() => Boolean)
isFeatured: boolean;
@Field(() => GraphQLJSON, { nullable: true })
manifest?: Manifest;
}
@@ -1,204 +1,12 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
@ObjectType('MarketplaceAppField')
export class MarketplaceAppFieldDTO {
@IsString()
@Field()
name: string;
@IsString()
@Field()
type: string;
@IsString()
@Field()
label: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
icon?: string;
@IsString()
@Field({ nullable: true })
objectUniversalIdentifier: string;
@IsString()
@Field({ nullable: true })
universalIdentifier: string;
}
@ObjectType('MarketplaceAppObject')
export class MarketplaceAppObjectDTO {
@IsString()
@Field()
universalIdentifier: string;
@IsString()
@Field()
nameSingular: string;
@IsString()
@Field()
namePlural: string;
@IsString()
@Field()
labelSingular: string;
@IsString()
@Field()
labelPlural: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
icon?: string;
@IsArray()
@Field(() => [MarketplaceAppFieldDTO])
fields: MarketplaceAppFieldDTO[];
}
@ObjectType('MarketplaceAppLogicFunction')
export class MarketplaceAppLogicFunctionDTO {
@IsString()
@Field()
name: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsNumber()
@Field(() => Int, { nullable: true })
timeoutSeconds?: number;
}
@ObjectType('MarketplaceAppFrontComponent')
export class MarketplaceAppFrontComponentDTO {
@IsString()
@Field()
name: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
}
@ObjectType('MarketplaceAppRoleObjectPermission')
export class MarketplaceAppRoleObjectPermissionDTO {
@IsString()
@Field()
objectUniversalIdentifier: string;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canReadObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canUpdateObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canSoftDeleteObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canDestroyObjectRecords?: boolean;
}
@ObjectType('MarketplaceAppRoleFieldPermission')
export class MarketplaceAppRoleFieldPermissionDTO {
@IsString()
@Field()
objectUniversalIdentifier: string;
@IsString()
@Field()
fieldUniversalIdentifier: string;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canReadFieldValue?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canUpdateFieldValue?: boolean;
}
@ObjectType('MarketplaceAppDefaultRole')
export class MarketplaceAppDefaultRoleDTO {
@IsString()
@IsNotEmpty()
@Field()
id: string;
@IsString()
@Field()
label: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@Field(() => Boolean)
canReadAllObjectRecords: boolean;
@Field(() => Boolean)
canUpdateAllObjectRecords: boolean;
@Field(() => Boolean)
canSoftDeleteAllObjectRecords: boolean;
@Field(() => Boolean)
canDestroyAllObjectRecords: boolean;
@Field(() => Boolean)
canUpdateAllSettings: boolean;
@Field(() => Boolean)
canAccessAllTools: boolean;
@IsArray()
@Field(() => [MarketplaceAppRoleObjectPermissionDTO])
objectPermissions: MarketplaceAppRoleObjectPermissionDTO[];
@IsArray()
@Field(() => [MarketplaceAppRoleFieldPermissionDTO])
fieldPermissions: MarketplaceAppRoleFieldPermissionDTO[];
@IsArray()
@Field(() => [String])
permissionFlags: string[];
}
@ObjectType('MarketplaceApp')
export class MarketplaceAppDTO {
@@ -221,10 +29,6 @@ export class MarketplaceAppDTO {
@Field()
icon: string;
@IsString()
@Field()
version: string;
@IsString()
@Field()
author: string;
@@ -238,50 +42,6 @@ export class MarketplaceAppDTO {
@Field({ nullable: true })
logo?: string;
@IsArray()
@Field(() => [String])
screenshots: string[];
@IsString()
@Field()
aboutDescription: string;
@IsArray()
@Field(() => [String])
providers: string[];
@IsOptional()
@IsString()
@Field({ nullable: true })
websiteUrl?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
termsUrl?: string;
@IsArray()
@Field(() => [MarketplaceAppObjectDTO])
objects: MarketplaceAppObjectDTO[];
@IsArray()
@Field(() => [MarketplaceAppFieldDTO])
fields: MarketplaceAppFieldDTO[];
@IsArray()
@Field(() => [MarketplaceAppLogicFunctionDTO])
logicFunctions: MarketplaceAppLogicFunctionDTO[];
@IsArray()
@Field(() => [MarketplaceAppFrontComponentDTO])
frontComponents: MarketplaceAppFrontComponentDTO[];
@IsOptional()
@ValidateNested()
@Type(() => MarketplaceAppDefaultRoleDTO)
@Field(() => MarketplaceAppDefaultRoleDTO, { nullable: true })
defaultRole?: MarketplaceAppDefaultRoleDTO;
@IsOptional()
@IsString()
@Field({ nullable: true })
@@ -4,6 +4,9 @@ import { ApplicationRegistrationService } from 'src/engine/core-modules/applicat
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class MarketplaceCatalogSyncService {
@@ -12,11 +15,12 @@ export class MarketplaceCatalogSyncService {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly marketplaceService: MarketplaceService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async syncCatalog(): Promise<void> {
await this.syncCuratedApps();
await this.syncNpmApps();
await this.syncRegistryApps();
this.logger.log('Marketplace catalog sync completed');
}
@@ -27,18 +31,12 @@ export class MarketplaceCatalogSyncService {
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier: entry.universalIdentifier,
name: entry.name,
description:
entry.richDisplayData.aboutDescription ?? entry.description,
author: entry.author,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: entry.sourcePackage,
logoUrl: entry.logoUrl ?? null,
websiteUrl: entry.websiteUrl ?? null,
termsUrl: entry.termsUrl ?? null,
latestAvailableVersion: entry.richDisplayData.version ?? null,
latestAvailableVersion: entry.latestAvailableVersion ?? null,
isListed: true,
isFeatured: entry.isFeatured,
marketplaceDisplayData: entry.richDisplayData,
manifest: null,
ownerWorkspaceId: null,
});
} catch (error) {
@@ -49,38 +47,59 @@ export class MarketplaceCatalogSyncService {
}
}
private async syncNpmApps(): Promise<void> {
const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry();
private async syncRegistryApps(): Promise<void> {
const packages = await this.marketplaceService.fetchAppsFromRegistry();
const curatedIdentifiers = new Set(
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
);
for (const app of npmApps) {
if (curatedIdentifiers.has(app.id)) {
continue;
}
for (const pkg of packages) {
try {
const manifest =
await this.marketplaceService.fetchManifestFromRegistryCdn(
pkg.name,
pkg.version,
);
if (!manifest) {
this.logger.debug(`Skipping ${pkg.name}: no manifest found on CDN`);
continue;
}
const universalIdentifier = manifest.application.universalIdentifier;
if (curatedIdentifiers.has(universalIdentifier)) {
continue;
}
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
buildRegistryCdnUrl({
cdnBaseUrl,
packageName: pkg.name,
version: pkg.version,
filePath,
}),
);
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier: app.id,
name: app.name,
description: app.description,
author: app.author,
universalIdentifier,
name: manifest.application.displayName ?? pkg.name,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: app.sourcePackage ?? app.name,
logoUrl: null,
websiteUrl: app.websiteUrl ?? null,
termsUrl: null,
latestAvailableVersion: app.version ?? null,
sourcePackage: pkg.name,
latestAvailableVersion: pkg.version ?? null,
isListed: true,
isFeatured: false,
marketplaceDisplayData: null,
manifest: manifestWithResolvedUrls,
ownerWorkspaceId: null,
});
} catch (error) {
this.logger.error(
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
`Failed to sync registry app "${pkg.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -10,17 +10,14 @@ import {
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
const MARKETPLACE_CACHE_TTL_MS = 5 * 60 * 1000;
@Injectable()
export class MarketplaceQueryService {
private readonly logger = new Logger(MarketplaceQueryService.name);
private cachedApps: MarketplaceAppDTO[] | null = null;
private cacheExpiresAt = 0;
private hasSyncBeenEnqueued = false;
constructor(
@@ -30,10 +27,6 @@ export class MarketplaceQueryService {
) {}
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
if (this.cachedApps !== null && Date.now() < this.cacheExpiresAt) {
return this.cachedApps;
}
const registrations =
await this.applicationRegistrationService.findManyListed();
@@ -46,27 +39,25 @@ export class MarketplaceQueryService {
await this.messageQueueService.add(
MarketplaceCatalogSyncCronJob.name,
{},
{ id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs
);
}
return [];
}
this.cachedApps = registrations.map((registration) =>
return registrations.map((registration) =>
this.toMarketplaceAppDTO(registration),
);
this.cacheExpiresAt = Date.now() + MARKETPLACE_CACHE_TTL_MS;
return this.cachedApps;
}
async findOneMarketplaceApp(
async findMarketplaceAppDetail(
universalIdentifier: string,
): Promise<MarketplaceAppDTO> {
): Promise<MarketplaceAppDetailDTO> {
const registration =
await this.findRegistrationByUniversalIdentifier(universalIdentifier);
return this.toMarketplaceAppDTO(registration);
return this.toMarketplaceAppDetailDTO(registration);
}
async findRegistrationByUniversalIdentifier(
@@ -87,34 +78,37 @@ export class MarketplaceQueryService {
return registration;
}
toMarketplaceAppDTO(
private toMarketplaceAppDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDTO {
const displayData = registration.marketplaceDisplayData;
const app = registration.manifest?.application;
return {
id: registration.universalIdentifier,
name: registration.name,
description: registration.description ?? '',
icon: displayData?.icon ?? 'IconApps',
version:
displayData?.version ?? registration.latestAvailableVersion ?? '0.0.0',
author: registration.author ?? 'Unknown',
category: displayData?.category ?? '',
logo: displayData?.logo,
screenshots: displayData?.screenshots ?? [],
aboutDescription:
displayData?.aboutDescription ?? registration.description ?? '',
providers: displayData?.providers ?? [],
websiteUrl: registration.websiteUrl ?? undefined,
termsUrl: registration.termsUrl ?? undefined,
objects: displayData?.objects ?? [],
fields: displayData?.fields ?? [],
logicFunctions: displayData?.logicFunctions ?? [],
frontComponents: displayData?.frontComponents ?? [],
name: app?.displayName ?? registration.name,
description: app?.description ?? '',
icon: app?.icon ?? 'IconApps',
author: app?.author ?? 'Unknown',
category: app?.category ?? '',
logo: app?.logoUrl ?? undefined,
sourcePackage: registration.sourcePackage ?? undefined,
defaultRole: displayData?.defaultRole,
isFeatured: registration.isFeatured,
};
}
private toMarketplaceAppDetailDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDetailDTO {
return {
id: registration.id,
universalIdentifier: registration.universalIdentifier,
name: registration.name,
sourceType: registration.sourceType,
sourcePackage: registration.sourcePackage ?? undefined,
latestAvailableVersion: registration.latestAvailableVersion ?? undefined,
isListed: registration.isListed,
isFeatured: registration.isFeatured,
manifest: registration.manifest ?? undefined,
};
}
}
@@ -11,6 +11,7 @@ import { MarketplaceService } from 'src/engine/core-modules/application/applicat
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.command';
@Module({
imports: [
@@ -26,6 +27,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
MarketplaceQueryService,
MarketplaceCatalogSyncCronJob,
MarketplaceCatalogSyncCronCommand,
MarketplaceCatalogSyncCommand,
MarketplaceResolver,
],
exports: [
@@ -6,6 +6,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -13,6 +14,10 @@ import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@MetadataResolver()
@UseFilters(ApplicationRegistrationExceptionFilter)
@@ -21,6 +26,8 @@ export class MarketplaceResolver {
constructor(
private readonly marketplaceQueryService: MarketplaceQueryService,
private readonly applicationInstallService: ApplicationInstallService,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@Query(() => [MarketplaceAppDTO])
@@ -28,11 +35,11 @@ export class MarketplaceResolver {
return this.marketplaceQueryService.findManyMarketplaceApps();
}
@Query(() => MarketplaceAppDTO)
async findOneMarketplaceApp(
@Query(() => MarketplaceAppDetailDTO)
async findMarketplaceAppDetail(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<MarketplaceAppDTO> {
return this.marketplaceQueryService.findOneMarketplaceApp(
): Promise<MarketplaceAppDetailDTO> {
return this.marketplaceQueryService.findMarketplaceAppDetail(
universalIdentifier,
);
}
@@ -56,4 +63,16 @@ export class MarketplaceResolver {
workspaceId: workspace.id,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
async syncMarketplaceCatalog(): Promise<boolean> {
await this.messageQueueService.add(
MarketplaceCatalogSyncCronJob.name,
{},
{ id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs
);
return true;
}
}
@@ -1,13 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { isDefined } from 'twenty-shared/utils';
import { type Manifest } from 'twenty-shared/application';
import { z } from 'zod';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmSearchResultSchema = z.object({
export type RegistryPackageInfo = {
name: string;
version: string;
description: string;
author: string;
websiteUrl?: string;
};
const registrySearchResultSchema = z.object({
objects: z.array(
z.object({
package: z.object({
@@ -16,7 +24,12 @@ const npmSearchResultSchema = z.object({
description: z.string().optional(),
keywords: z.array(z.string()).optional(),
author: z.object({ name: z.string().optional() }).optional(),
links: z.object({ homepage: z.string().optional() }).optional(),
links: z
.object({
homepage: z.string().optional(),
npm: z.string().optional(),
})
.optional(),
}),
}),
),
@@ -28,7 +41,39 @@ export class MarketplaceService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async fetchAppsFromNpmRegistry(): Promise<MarketplaceAppDTO[]> {
async fetchManifestFromRegistryCdn(
packageName: string,
version: string,
): Promise<Manifest | null> {
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const url = buildRegistryCdnUrl({
cdnBaseUrl,
packageName,
version,
filePath: 'manifest.json',
});
try {
const { data } = await axios.get(url, {
headers: { 'User-Agent': 'Twenty-Marketplace' },
timeout: 5_000,
});
if (!data?.application) {
return null;
}
return data as Manifest;
} catch {
this.logger.debug(
`Could not fetch manifest from CDN for ${packageName}@${version}`,
);
return null;
}
}
async fetchAppsFromRegistry(): Promise<RegistryPackageInfo[]> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
try {
@@ -40,53 +85,30 @@ export class MarketplaceService {
},
);
const parsed = npmSearchResultSchema.safeParse(data);
const parsed = registrySearchResultSchema.safeParse(data);
if (!parsed.success) {
this.logger.warn(
`Unexpected npm search response shape: ${parsed.error.message}`,
`Unexpected registry search response shape: ${parsed.error.message}`,
);
return [];
}
return parsed.data.objects
.map((result) => {
const { name, version, description, author, links } = result.package;
const twentyKeyword = (result.package.keywords ?? []).find(
(keyword) => keyword.startsWith('twenty-uid:'),
);
return parsed.data.objects.map((result) => {
const { name, version, description, author, links } = result.package;
if (!isDefined(twentyKeyword)) {
return null;
}
const universalIdentifier = twentyKeyword.replace('twenty-uid:', '');
return {
id: universalIdentifier,
name,
description: description ?? '',
icon: 'IconApps',
version,
author: author?.name ?? 'Unknown',
category: '',
screenshots: [],
aboutDescription: description ?? '',
providers: [],
websiteUrl: links?.homepage,
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
sourcePackage: name,
isFeatured: false,
};
})
.filter(isDefined);
return {
name,
version,
description: description ?? '',
author: author?.name ?? 'Unknown',
websiteUrl: links?.homepage ?? links?.npm,
};
});
} catch (error) {
this.logger.warn(
`Failed to fetch apps from npm registry: ${error instanceof Error ? error.message : String(error)}`,
`Failed to fetch apps from registry ${registryUrl}: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
@@ -1,75 +0,0 @@
// Rich display data stored alongside ApplicationRegistration for marketplace
// rendering. This is denormalized from the catalog source so it can be displayed
// pre-install without resolving the package.
export type MarketplaceDisplayData = {
icon?: string;
version?: string;
category?: string;
logo?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
objects?: MarketplaceDisplayObject[];
fields?: MarketplaceDisplayField[];
logicFunctions?: MarketplaceDisplayLogicFunction[];
frontComponents?: MarketplaceDisplayFrontComponent[];
defaultRole?: MarketplaceDisplayDefaultRole;
};
type MarketplaceDisplayObject = {
universalIdentifier: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
fields: MarketplaceDisplayField[];
};
type MarketplaceDisplayField = {
name: string;
type: string;
label: string;
description?: string;
icon?: string;
objectUniversalIdentifier: string;
universalIdentifier: string;
};
type MarketplaceDisplayLogicFunction = {
name: string;
description?: string;
timeoutSeconds?: number;
};
type MarketplaceDisplayFrontComponent = {
name: string;
description?: string;
};
type MarketplaceDisplayDefaultRole = {
id: string;
label: string;
description?: string;
canReadAllObjectRecords: boolean;
canUpdateAllObjectRecords: boolean;
canSoftDeleteAllObjectRecords: boolean;
canDestroyAllObjectRecords: boolean;
canUpdateAllSettings: boolean;
canAccessAllTools: boolean;
objectPermissions: Array<{
objectUniversalIdentifier: string;
canReadObjectRecords?: boolean;
canUpdateObjectRecords?: boolean;
canSoftDeleteObjectRecords?: boolean;
canDestroyObjectRecords?: boolean;
}>;
fieldPermissions: Array<{
objectUniversalIdentifier: string;
fieldUniversalIdentifier: string;
canReadFieldValue?: boolean;
canUpdateFieldValue?: boolean;
}>;
permissionFlags: string[];
};
@@ -0,0 +1,145 @@
import { type Manifest } from 'twenty-shared/application';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
const buildMinimalManifest = (
overrides: Partial<Manifest['application']> = {},
): Manifest => ({
application: {
universalIdentifier: 'app-1',
defaultRoleUniversalIdentifier: 'role-1',
displayName: 'Test App',
description: 'A test app',
packageJsonChecksum: null,
yarnLockChecksum: null,
...overrides,
},
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
roles: [],
skills: [],
agents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
});
describe('resolveManifestAssetUrls', () => {
const urlBuilder = (filePath: string) =>
`https://cdn.example.com/pkg/${filePath}`;
it('should resolve a relative logoUrl', () => {
const manifest = buildMinimalManifest({ logoUrl: 'logo.png' });
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe(
'https://cdn.example.com/pkg/logo.png',
);
});
it('should not modify an absolute logoUrl', () => {
const manifest = buildMinimalManifest({
logoUrl: 'https://example.com/logo.png',
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe('https://example.com/logo.png');
});
it('should not modify an http logoUrl', () => {
const manifest = buildMinimalManifest({
logoUrl: 'http://example.com/logo.png',
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe('http://example.com/logo.png');
});
it('should leave logoUrl undefined when not set', () => {
const manifest = buildMinimalManifest();
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBeUndefined();
});
it('should resolve relative screenshot paths', () => {
const manifest = buildMinimalManifest({
screenshots: ['screen1.png', 'screen2.png'],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://cdn.example.com/pkg/screen1.png',
'https://cdn.example.com/pkg/screen2.png',
]);
});
it('should not modify absolute screenshot URLs', () => {
const manifest = buildMinimalManifest({
screenshots: [
'https://example.com/screen1.png',
'https://example.com/screen2.png',
],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://example.com/screen1.png',
'https://example.com/screen2.png',
]);
});
it('should handle a mix of relative and absolute screenshot URLs', () => {
const manifest = buildMinimalManifest({
screenshots: ['relative.png', 'https://example.com/absolute.png'],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://cdn.example.com/pkg/relative.png',
'https://example.com/absolute.png',
]);
});
it('should handle empty screenshots array', () => {
const manifest = buildMinimalManifest({ screenshots: [] });
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([]);
});
it('should handle undefined screenshots', () => {
const manifest = buildMinimalManifest();
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([]);
});
it('should preserve all other manifest properties', () => {
const manifest = buildMinimalManifest({
logoUrl: 'logo.png',
author: 'Test Author',
websiteUrl: 'https://test.com',
});
manifest.objects = [{ universalIdentifier: 'obj-1' } as never];
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.author).toBe('Test Author');
expect(result.application.websiteUrl).toBe('https://test.com');
expect(result.application.displayName).toBe('Test App');
expect(result.objects).toEqual([{ universalIdentifier: 'obj-1' }]);
});
});
@@ -0,0 +1,8 @@
export const buildRegistryCdnUrl = (params: {
cdnBaseUrl: string;
packageName: string;
version: string;
filePath: string;
}): string => {
return `${params.cdnBaseUrl}/${params.packageName}@${params.version}/${params.filePath}`;
};
@@ -0,0 +1,23 @@
import { type Manifest } from 'twenty-shared/application';
const isAbsoluteUrl = (url: string): boolean =>
url.startsWith('http://') || url.startsWith('https://');
export const resolveManifestAssetUrls = (
manifest: Manifest,
urlBuilder: (filePath: string) => string,
): Manifest => {
const resolveUrl = (url: string): string =>
isAbsoluteUrl(url) ? url : urlBuilder(url);
return {
...manifest,
application: {
...manifest.application,
logoUrl: manifest.application.logoUrl
? resolveUrl(manifest.application.logoUrl)
: undefined,
screenshots: (manifest.application.screenshots ?? []).map(resolveUrl),
},
};
};
@@ -140,9 +140,6 @@ export class OAuthRegistrationController {
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: v4(),
name: body.client_name,
description: null,
logoUrl: body.logo_uri ?? null,
author: null,
oAuthClientId: clientId,
oAuthClientSecretHash: null,
oAuthRedirectUris: body.redirect_uris,
@@ -150,7 +147,6 @@ export class OAuthRegistrationController {
createdByUserId: null,
ownerWorkspaceId: null,
sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY,
websiteUrl: body.client_uri ?? null,
});
await this.applicationRegistrationRepository.save(registration);
@@ -586,7 +586,6 @@ export class OAuthService {
return this.applicationService.create({
universalIdentifier: applicationRegistration.universalIdentifier,
name: applicationRegistration.name,
description: applicationRegistration.description,
version: '0.0.0',
sourcePath: 'oauth-install',
applicationRegistrationId: applicationRegistration.id,
@@ -18,7 +18,7 @@ import {
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
import { type Manifest } from 'twenty-shared/application';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -62,18 +62,6 @@ export class ApplicationRegistrationEntity {
@Column({ nullable: false, type: 'text' })
name: string;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
description: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
logoUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
author: string | null;
@Field()
@Column({ nullable: false, type: 'text' })
oAuthClientId: string;
@@ -128,14 +116,6 @@ export class ApplicationRegistrationEntity {
@Column({ nullable: true, type: 'text' })
latestAvailableVersion: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
websiteUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false })
isListed: boolean;
@@ -145,7 +125,7 @@ export class ApplicationRegistrationEntity {
isFeatured: boolean;
@Column({ type: 'jsonb', nullable: true })
marketplaceDisplayData: MarketplaceDisplayData | null;
manifest: Manifest | null;
@OneToMany(
() => ApplicationRegistrationVariableEntity,
@@ -8,6 +8,7 @@ import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/a
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
@@ -24,6 +25,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
]),
ApplicationRegistrationVariableModule,
ApplicationModule,
DomainServerConfigModule,
FeatureFlagModule,
PermissionsModule,
FileStorageModule,
@@ -32,6 +32,7 @@ import { RotateClientSecretDTO } from 'src/engine/core-modules/application/appli
import { TransferApplicationRegistrationOwnershipInput } from 'src/engine/core-modules/application/application-registration/dtos/transfer-application-registration-ownership.input';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -59,6 +60,7 @@ export class ApplicationRegistrationResolver {
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly fileUrlService: FileUrlService,
private readonly domainServerConfigService: DomainServerConfigService,
) {}
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -290,6 +292,25 @@ export class ApplicationRegistrationResolver {
});
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => String)
async getApplicationShareLink(
@Args('id') id: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<string> {
const registration = await this.applicationRegistrationService.findOneById(
id,
workspaceId,
);
const frontUrl = this.domainServerConfigService.getFrontUrl();
return `${frontUrl.origin}/settings/applications/available/${registration.universalIdentifier}`;
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, type Repository } from 'typeorm';
import { v4 } from 'uuid';
@@ -102,7 +103,7 @@ export class ApplicationRegistrationService {
): Promise<PublicApplicationRegistrationDTO | null> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { oAuthClientId: clientId },
select: ['id', 'name', 'logoUrl', 'websiteUrl', 'oAuthScopes'],
select: ['id', 'name', 'manifest', 'oAuthScopes'],
});
if (!registration) {
@@ -112,22 +113,12 @@ export class ApplicationRegistrationService {
return {
id: registration.id,
name: registration.name,
logoUrl: registration.logoUrl,
websiteUrl: registration.websiteUrl,
logoUrl: registration.manifest?.application?.logoUrl ?? null,
websiteUrl: registration.manifest?.application?.websiteUrl ?? null,
oAuthScopes: registration.oAuthScopes,
};
}
async isOwnedByWorkspace(id: string, workspaceId: string): Promise<boolean> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
select: ['id', 'ownerWorkspaceId'],
});
return registration?.ownerWorkspaceId === workspaceId;
}
// Global lookup — used by app sync to find existing registrations
async findOneByUniversalIdentifier(
universalIdentifier: string,
): Promise<ApplicationRegistrationEntity | null> {
@@ -172,17 +163,12 @@ export class ApplicationRegistrationService {
this.applicationRegistrationRepository.create({
universalIdentifier,
name: input.name,
description: input.description ?? null,
logoUrl: input.logoUrl ?? null,
author: input.author ?? null,
oAuthClientId: clientId,
oAuthClientSecretHash: clientSecretHash,
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
oAuthScopes: input.oAuthScopes ?? [],
createdByUserId,
ownerWorkspaceId,
websiteUrl: input.websiteUrl ?? null,
termsUrl: input.termsUrl ?? null,
});
const saved = await this.applicationRegistrationRepository.save(
@@ -211,16 +197,10 @@ export class ApplicationRegistrationService {
const updateData: Record<string, unknown> = {};
if (isDefined(update.name)) updateData.name = update.name;
if (isDefined(update.description))
updateData.description = update.description;
if (isDefined(update.logoUrl)) updateData.logoUrl = update.logoUrl;
if (isDefined(update.author)) updateData.author = update.author;
if (isDefined(update.oAuthRedirectUris))
updateData.oAuthRedirectUris = update.oAuthRedirectUris;
if (isDefined(update.oAuthScopes))
updateData.oAuthScopes = update.oAuthScopes;
if (isDefined(update.websiteUrl)) updateData.websiteUrl = update.websiteUrl;
if (isDefined(update.termsUrl)) updateData.termsUrl = update.termsUrl;
if (isDefined(update.isListed)) updateData.isListed = update.isListed;
if (Object.keys(updateData).length > 0) {
@@ -230,6 +210,21 @@ export class ApplicationRegistrationService {
return this.findOneById(id, ownerWorkspaceId);
}
async updateFromManifest(
applicationRegistrationId: string,
manifest: Manifest,
): Promise<void> {
const existing = await this.applicationRegistrationRepository.findOneOrFail(
{ where: { id: applicationRegistrationId } },
);
await this.applicationRegistrationRepository.save({
...existing,
name: manifest.application.displayName,
manifest,
});
}
async delete(id: string, ownerWorkspaceId: string): Promise<boolean> {
await this.findOneById(id, ownerWorkspaceId);
await this.applicationRegistrationRepository.softDelete(id);
@@ -269,17 +264,12 @@ export class ApplicationRegistrationService {
ApplicationRegistrationEntity,
| 'universalIdentifier'
| 'name'
| 'description'
| 'author'
| 'sourceType'
| 'sourcePackage'
| 'logoUrl'
| 'websiteUrl'
| 'termsUrl'
| 'latestAvailableVersion'
| 'isListed'
| 'isFeatured'
| 'marketplaceDisplayData'
| 'manifest'
| 'ownerWorkspaceId'
>,
): Promise<void> {
@@ -291,15 +281,12 @@ export class ApplicationRegistrationService {
await this.applicationRegistrationRepository.save({
...existing,
name: params.name,
description: params.description,
author: params.author,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
logoUrl: params.logoUrl,
websiteUrl: params.websiteUrl,
termsUrl: params.termsUrl,
latestAvailableVersion: params.latestAvailableVersion,
marketplaceDisplayData: params.marketplaceDisplayData,
manifest: params.manifest,
isListed: params.isListed,
isFeatured: params.isFeatured,
});
return;
@@ -308,17 +295,12 @@ export class ApplicationRegistrationService {
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
description: params.description,
author: params.author,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
logoUrl: params.logoUrl,
websiteUrl: params.websiteUrl,
termsUrl: params.termsUrl,
latestAvailableVersion: params.latestAvailableVersion,
isListed: params.isListed,
isFeatured: params.isFeatured,
marketplaceDisplayData: params.marketplaceDisplayData,
manifest: params.manifest,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
@@ -341,7 +323,6 @@ export class ApplicationRegistrationService {
universalIdentifier:
TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier,
name: TWENTY_CLI_APPLICATION_REGISTRATION.name,
description: TWENTY_CLI_APPLICATION_REGISTRATION.description,
oAuthClientId: v4(),
oAuthClientSecretHash: null,
oAuthRedirectUris: [],
@@ -63,6 +63,10 @@ export class ApplicationTarballService {
};
}>(contentDir, 'manifest.json');
const packageJson = await readJsonFile<{
version: string;
}>(contentDir, 'package.json');
if (manifest === null) {
throw new ApplicationRegistrationException(
'manifest.json not found or invalid in tarball',
@@ -104,6 +108,10 @@ export class ApplicationTarballService {
universalIdentifier,
name: manifest.application?.displayName ?? 'Unknown App',
sourceType: ApplicationRegistrationSourceType.TARBALL,
manifest,
latestAvailableVersion: packageJson?.version ?? null,
isListed: false,
isFeatured: false,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
@@ -137,6 +145,12 @@ export class ApplicationTarballService {
await this.appRegistrationRepository.update(appRegistration.id, {
sourceType: ApplicationRegistrationSourceType.TARBALL,
tarballFileId: savedFile.id,
name: manifest.application?.displayName ?? 'Unknown App',
manifest,
latestAvailableVersion: packageJson?.version ?? null,
isListed: false,
isFeatured: false,
ownerWorkspaceId: params.ownerWorkspaceId,
});
this.logger.log(
@@ -16,24 +16,6 @@ export class CreateApplicationRegistrationInput {
@MaxLength(256)
name: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
author?: string;
@Field({ nullable: true })
@IsUUID()
@IsOptional()
@@ -54,16 +36,4 @@ export class CreateApplicationRegistrationInput {
@MaxLength(256, { each: true })
@IsOptional()
oAuthScopes?: string[];
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
}
@@ -21,24 +21,6 @@ export class UpdateApplicationRegistrationPayload {
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
author?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(20)
@@ -55,18 +37,6 @@ export class UpdateApplicationRegistrationPayload {
@IsOptional()
oAuthScopes?: string[];
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
@@ -1602,6 +1602,16 @@ export class ConfigVariables {
@IsOptional()
APP_REGISTRY_URL: string = 'https://registry.npmjs.org';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description:
'CDN base URL for serving files from registry (e.g. https://unpkg.com)',
type: ConfigVariableType.STRING,
})
@IsUrl({ require_tld: false })
@IsOptional()
APP_REGISTRY_CDN_URL: string = 'https://unpkg.com';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
isSensitive: true,
@@ -248,7 +248,6 @@ describe('ApplicationRegistrationVariable (integration)', () => {
variables: {
input: {
name: 'GQL Variable Test App',
description: 'Created via GraphQL for variable testing',
},
},
});
@@ -14,8 +14,9 @@ const MARKETPLACE_QUERY = `
author
sourcePackage
icon
version
category
logo
isFeatured
}
}
`;
@@ -58,7 +59,7 @@ describe('Marketplace Catalog Sync (integration)', () => {
name: string;
sourcePackage: string;
latestAvailableVersion?: string;
marketplaceDisplayData?: Record<string, unknown>;
manifest?: Record<string, unknown>;
}): Promise<string> => {
const id = crypto.randomUUID();
const oAuthClientId = crypto.randomUUID();
@@ -68,7 +69,7 @@ describe('Marketplace Catalog Sync (integration)', () => {
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId",
"sourceType", "sourcePackage", "latestAvailableVersion",
"marketplaceDisplayData", "isListed")
"manifest", "isListed")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
id,
@@ -81,9 +82,7 @@ describe('Marketplace Catalog Sync (integration)', () => {
'npm',
params.sourcePackage,
params.latestAvailableVersion ?? '1.0.0',
params.marketplaceDisplayData
? JSON.stringify(params.marketplaceDisplayData)
: null,
params.manifest ? JSON.stringify(params.manifest) : null,
true,
],
);
@@ -134,10 +133,11 @@ describe('Marketplace Catalog Sync (integration)', () => {
universalIdentifier: curatedUid,
name: 'Data Enrichment',
sourcePackage: '@twentyhq/app-data-enrichment',
marketplaceDisplayData: {
icon: 'IconSparkles',
version: '1.0.0',
category: 'Data',
manifest: {
application: {
icon: 'IconSparkles',
category: 'Data',
},
},
});
});
@@ -21,14 +21,13 @@ export const setupApplicationForSync = async ({
await globalThis.testDataSource.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, description, "oAuthClientId",
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
registrationId,
applicationUniversalIdentifier,
name,
description,
oAuthClientId,
[],
[],
@@ -14,7 +14,6 @@ type TestRegistration = {
id: string;
universalIdentifier: string;
name: string;
description: string | null;
oAuthClientId: string;
oAuthRedirectUris: string[];
oAuthScopes: string[];
@@ -28,7 +27,6 @@ const insertRegistration = async (
ds: DataSource,
params: {
name: string;
description?: string;
clientSecretHash: string;
redirectUris: string[];
scopes: string[];
@@ -40,13 +38,12 @@ const insertRegistration = async (
await ds.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, description, "oAuthClientId", "oAuthClientSecretHash", "oAuthRedirectUris", "oAuthScopes", "workspaceId")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
(id, "universalIdentifier", name, "oAuthClientId", "oAuthClientSecretHash", "oAuthRedirectUris", "oAuthScopes", "workspaceId")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
universalIdentifier,
params.name,
params.description ?? null,
oAuthClientId,
params.clientSecretHash,
params.redirectUris,
@@ -59,7 +56,6 @@ const insertRegistration = async (
id,
universalIdentifier,
name: params.name,
description: params.description ?? null,
oAuthClientId,
oAuthRedirectUris: params.redirectUris,
oAuthScopes: params.scopes,
@@ -169,7 +165,6 @@ describe('OAuth (integration)', () => {
autoInstallRegistration = await insertRegistration(ds, {
name: 'OAuth Auto-Install Test App',
description: 'App for testing OAuth auto-install',
clientSecretHash: autoInstallSecretHash,
redirectUris: ['https://example.com/callback'],
scopes: ['api'],
@@ -583,9 +578,6 @@ describe('OAuth (integration)', () => {
const autoCreatedApp = rows[0];
expect(autoCreatedApp.name).toBe('OAuth Auto-Install Test App');
expect(autoCreatedApp.description).toBe(
'App for testing OAuth auto-install',
);
expect(autoCreatedApp.sourcePath).toBe('oauth-install');
expect(autoCreatedApp.universalIdentifier).toBe(
autoInstallRegistration.universalIdentifier,
@@ -1,6 +1,6 @@
import 'tsconfig-paths/register';
export default async () => {
global.testDataSource.destroy();
global.app.close();
await global.app.close();
await global.testDataSource.destroy();
};
@@ -14,9 +14,10 @@ export type ApplicationManifest = SyncableEntityOptions & {
logoUrl?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
websiteUrl?: string;
termsUrl?: string;
emailSupport?: string;
issueReportUrl?: string;
preInstallLogicFunctionUniversalIdentifier?: string;
postInstallLogicFunctionUniversalIdentifier?: string;
settingsCustomTabFrontComponentUniversalIdentifier?: string;
@@ -32,6 +32,7 @@ export {
IconBlockquote,
IconBold,
IconBolt,
IconBook,
IconBook2,
IconBookmark,
IconBookmarkPlus,
@@ -198,6 +199,7 @@ export {
IconGitBranchDeleted,
IconGitCommit,
IconGizmo,
IconGraph,
IconGripVertical,
IconH1,
IconH2,
@@ -242,6 +244,7 @@ export {
IconLayoutSidebarRight,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconLego,
IconLetterK,
IconLibraryPlus,
IconLifebuoy,
+3
View File
@@ -110,6 +110,7 @@ export {
IconBlockquote,
IconBold,
IconBolt,
IconBook,
IconBook2,
IconBookmark,
IconBookmarkPlus,
@@ -276,6 +277,7 @@ export {
IconGitBranchDeleted,
IconGitCommit,
IconGizmo,
IconGraph,
IconGripVertical,
IconH1,
IconH2,
@@ -320,6 +322,7 @@ export {
IconLayoutSidebarRight,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconLego,
IconLetterK,
IconLibraryPlus,
IconLifebuoy,