Compare commits

...
Author SHA1 Message Date
Félix Malfait 84894bd199 chore: trigger CI (pr is open)
Made-with: Cursor
2026-03-05 00:37:13 +01:00
Félix Malfait 9f505366ba chore: trigger CI
Made-with: Cursor
2026-03-05 00:35:42 +01:00
Félix Malfait 7768a9e24f chore: trigger CI for server integration tests
Made-with: Cursor
2026-03-05 00:30:32 +01:00
Félix Malfait aeb4925d91 fix: resolve circular dependency and set OAuth sourcePath
- Move ApplicationQueryResolver from ApplicationModule to
  ApplicationInstallModule to break circular dependency with
  PermissionsModule/TokenModule
- Add PermissionsModule import to ApplicationDevelopmentModule for
  SettingsPermissionGuard
- Set sourcePath to 'oauth-install' for OAuth auto-installed apps

Made-with: Cursor
2026-03-05 00:23:54 +01:00
Félix Malfait af8495e174 fix: add PermissionsModule import to ApplicationDevelopmentModule
SettingsPermissionGuard used in the resolver depends on
PermissionsService which must be available in the module context.

Made-with: Cursor
2026-03-04 23:35:54 +01:00
Félix Malfait 05235eaa2c fix: resolve circular dependency by moving ApplicationQueryResolver to ApplicationInstallModule
ApplicationModule importing TokenModule and PermissionsModule created a
circular dependency causing "Cannot access PermissionsModule before
initialization" at runtime. Keep ApplicationModule as a pure service
module and register the resolver in ApplicationInstallModule which
already has those dependencies.

Made-with: Cursor
2026-03-04 23:29:39 +01:00
Félix Malfait 7b10a53979 Merge remote-tracking branch 'origin/main' into feat/app-distribution-npm-tarball
Made-with: Cursor

# Conflicts:
#	packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts
#	packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command.ts
#	packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-favorites-to-navigation-menu-items.command.ts
#	packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command.ts
#	packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command.ts
#	packages/twenty-server/src/database/commands/upgrade-version-command/1-19/1-19-backfill-page-layouts.command.ts
#	packages/twenty-server/src/engine/core-modules/application/application-install/application-install.resolver.ts
#	packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts
#	packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts
#	packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts
2026-03-04 23:16:04 +01:00
Félix Malfait f5b952ac26 Reorganize 2026-03-04 23:02:13 +01:00
Félix Malfait c68512b6ec fix: add @Check decorator to entity for TypeORM migration sync
TypeORM migration:generate detects CHECK constraints not declared in
entity metadata and tries to drop them. Adding the @Check decorator
keeps the entity and migrations in sync.

Made-with: Cursor
2026-03-04 21:35:57 +01:00
Félix Malfait 1ccc8dbcb4 Add entity CHECK constraint + replace pg_advisory_xact_lock with Redis lock
- Add CHK_NPM_HAS_SOURCE_PACKAGE constraint: NPM registrations must have
  sourcePackage set (enforced at the database level)
- Replace pg_advisory_xact_lock with CacheLockService.withLock() for app
  install mutual exclusion, using the same Redis-based distributed locking
  pattern used elsewhere in the codebase
- Remove DataSource dependency, computeLockKeys/hashString helpers, and
  manual transaction management from ApplicationInstallService
- Extract doInstallApplication private method for the locked critical section

Made-with: Cursor
2026-03-04 21:09:34 +01:00
Félix Malfait d4bd43a68a fix: prettier formatting in marketplace integration test
Made-with: Cursor
2026-03-04 19:47:14 +01:00
Félix Malfait cc96f112e7 fix: restructure marketplace integration test for cache compatibility
Move all data insertions to beforeAll so the in-memory cache is populated
with complete data on the first query, avoiding stale cache hits in
subsequent test cases.

Made-with: Cursor
2026-03-04 19:35:53 +01:00
Félix Malfait 826e01d605 fix: add sourceType to generated gql template literals
The codegen check caught that the embedded gql strings in the generated
metadata file were missing the sourceType field added to the fragments.

Made-with: Cursor
2026-03-04 19:13:16 +01:00
Félix Malfait 124e0a1aae Address PR review feedback: code quality and SDK improvements
- Replace fetch with axios + zod validation in marketplace and upgrade services
- Remove rollback logic in upgradeApplication (sync is transactional)
- Reject tarball source type in upgradeApplication (like local)
- Stop setting latestAvailableVersion for tarball uploads
- Add sourceType to ApplicationRegistrationSummaryDTO for frontend
- Skip version/upgrade UI for non-NPM apps on frontend
- Auto-install after tarball upload in SDK app:push
- Remove AppRegistrationSourceType re-export from entity file
- Avoid mutation in marketplace-catalog-sync upsertRegistration
- Use Pick<Entity> for upsertRegistration params type
- Split readJsonFile into non-throwing + readJsonFileOrThrow variants
- Refactor writeNpmrc to use config object parameter
- Enable @ValidateNested on marketplace DTO defaultRole field
- Add in-memory cache with 5min TTL for marketplace apps query
- Update app:push CLI description for air-gapped/dev clarity
- Add TODOs for MetadataClient migration and pack extraction

Made-with: Cursor
2026-03-04 19:03:46 +01:00
Félix Malfait 25b322d2dc Fix 2026-03-04 16:51:44 +01:00
Félix Malfait 60614511af Fix 2026-03-04 16:44:17 +01:00
Félix Malfait 79184e6819 fix: resolve CI failures (import path, prettier, CLI options)
- Fix SettingsAppModalLayout.ts: import Modal from 'twenty-ui/layout'
  instead of non-existent '@/ui/layout/modal/components/Modal'
- Fix Prettier formatting in upload-app-tarball-query-factory.util.ts
- Fix Prettier formatting in api-service.ts uploadAppTarball method
- Restore --server and --token CLI overrides in app:push command by
  adding serverUrl/token options to ApiService constructor

Made-with: Cursor
2026-03-04 15:58:54 +01:00
Félix Malfait 145abaa612 Migrate app tarball upload from REST to GraphQL
- Update SDK AppPushCommand to use ApiService.uploadAppTarball (GraphQL
  multipart upload) instead of the deleted REST endpoint
- Add uploadAppTarball method to ApiService following existing upload pattern
- Rewrite app-distribution integration tests to use GraphQL mutations
- Add upload-app-tarball query factory and utility for test infrastructure

Made-with: Cursor
2026-03-04 15:35:58 +01:00
Félix Malfait aced6349dc Merge remote-tracking branch 'origin/main' into feat/app-distribution-npm-tarball
Made-with: Cursor

# Conflicts:
#	packages/twenty-apps/hello-world/package.json
#	packages/twenty-apps/hello-world/yarn.lock
2026-03-04 15:34:05 +01:00
Félix Malfait 34b83daeb7 Fix 2026-03-04 15:26:38 +01:00
Félix Malfait be7bc82a2e Refacto 2026-03-04 14:43:24 +01:00
Félix Malfait 9e34c42dc2 fix: stop hardcoding sourceType to LOCAL in application create
application.service.create() was overriding sourceType to LOCAL
regardless of what the caller passed. For npm/tarball installs, the
correct sourceType was set via updateApplicationSourceType, but it
ran BEFORE synchronizeFromManifest (which creates the Application
record), so the update hit zero rows on first install.

Fix: remove the hardcoded override (column default handles the
fallback) and move updateApplicationSourceType after the sync so it
correctly updates the newly-created record.

Made-with: Cursor
2026-03-04 13:48:44 +01:00
Félix Malfait 97f3bc3da5 fix: update integration tests and exception filter for refactored types
- Update app-distribution tests: 'none' sourceType → 'local'
- Handle ApplicationRegistrationException in REST API exception filter
  so tarball upload errors return proper HTTP status codes
- Catch manifest.json read failure in tarball upload and throw
  INVALID_INPUT (400) instead of PACKAGE_RESOLUTION_FAILED (500)
- Update marketplace-catalog-sync test to include marketplaceDisplayData
  in raw SQL inserts (matching what the real sync cron produces)

Made-with: Cursor
2026-03-04 13:29:28 +01:00
Félix Malfait 682bcd0186 fix: extract AppRegistrationSourceType to its own file
Break circular import chain: application-registration.entity →
user.entity → app-token.entity → workspace.entity →
application.entity → (back to) application-registration.entity.

The enum is now in its own file with no entity imports, so both entity
files can reference it without triggering a circular dependency at
module load time.

Made-with: Cursor
2026-03-04 13:05:33 +01:00
Félix Malfait f5243000e0 chore: regenerate GraphQL types after marketplace module extraction
Run graphql:generate and graphql:generate --configuration=metadata to
pick up the new installNpmApp mutation and updated schema.

Made-with: Cursor
2026-03-04 12:39:59 +01:00
Félix Malfait cd80fbf29d fix: register MarketplaceModule in CoreEngineModule
MarketplaceModule was only imported in DatabaseCommandModule (CLI) but
not in the main app module tree, so the resolver and cron were not
loaded at runtime.

Made-with: Cursor
2026-03-04 12:30:02 +01:00
Félix Malfait 2cc1e6243f fix: add PermissionsModule to MarketplaceModule, fix prettier
- MarketplaceResolver uses SettingsPermissionGuard which injects
  PermissionsService — need PermissionsModule in imports
- Fix prettier formatting in uploadAppTarball GraphQL mutation

Made-with: Cursor
2026-03-04 12:17:24 +01:00
Félix Malfait fba4fc789b refactor: extract marketplace module, fix types, clean up architecture
- Extract marketplace into dedicated NestJS module (marketplace/) from
  the overloaded application/ module (services, resolver, DTOs, crons,
  constants, types, utils)
- Fix MarketplaceDisplayData type mismatches with DTO (field types,
  required arrays, optional permission booleans)
- Fix cross-module exception: app-tarball-upload now throws
  ApplicationRegistrationException instead of ApplicationException
- Move MarketplaceResolver from ApplicationSyncModule to MarketplaceModule
- Remove redundant useApolloClient from useUploadAppTarball
- Move frontend marketplace hooks/mutations to modules/marketplace/
- Unify version comparison to use isNewerSemver consistently
- Validate npm registry response shape instead of unsafe cast
- Clarify workspaceId ownership semantics on ApplicationRegistration

Made-with: Cursor
2026-03-04 12:07:59 +01:00
Félix Malfait cb6d04862c Improve 2026-03-04 10:52:09 +01:00
Félix Malfait ea4e50679a refactor: remove premature registryUrl from ApplicationRegistration entity
The per-registration registry URL override was never set by any code
path — all npm operations already fall back to the server-level
APP_REGISTRY_URL config. Remove the column, entity field, and migration
to avoid unnecessary schema complexity. Can be re-added later if
per-app private registries become a real use case.

Made-with: Cursor
2026-03-04 09:44:42 +01:00
Félix Malfait 83e1efc496 fix: typecheck and prettier errors in upload controller and upgrade service
Made-with: Cursor
2026-03-04 09:38:15 +01:00
Félix Malfait a1c246eb92 fix: address follow-up review items
1. Advisory lock: use pg_advisory_xact_lock(key1, key2) with two
   separate INT4 hashes for 64-bit key space instead of single 32-bit
2. Update badge: use proper semver comparison (isNewerSemver) instead
   of simple inequality check, preventing false positives on downgrades
3. Catalog sync: replace boolean hasSyncedOnce with timestamp-based
   cooldown (5min), enabling retries after transient failures
4. Yarn engine: dynamically resolve yarn path from .yarnrc.yml instead
   of hardcoding the version in the filename
5. Temp directory: clean up APP_RESOLVER_TMPDIR on module init to
   remove stale files from previous server runs

Made-with: Cursor
2026-03-04 09:36:40 +01:00
Félix Malfait 86d98a5830 fix: address PR review comments (security, correctness, UX)
1. SDK push: fix endpoint URL (/api/app-registrations/) and send
   base64 JSON body instead of multipart/form-data
2. Security: skip symlinks in collectFiles to prevent path traversal
3. Security: use resolve()+startsWith() instead of includes('..')
   for tarball path traversal protection
4. Multi-tenant: scope upsertRegistration by workspaceId
5. Data consistency: set sourceType=TARBALL on creation instead of
   saving as NONE then updating
6. Correctness: move hasSyncedOnce after successful sync
7. Correctness: URL-encode scoped npm package names in registry URLs
8. Error handling: wrap JSON.parse in try-catch for manifest parsing
9. Security: remove filesystem paths from error messages
10. Robustness: add default branch to REST exception filter
11. UX: gate modal close on install success
12. Robustness: add default case to mapSourceType
13. UX: improve userFriendlyMessage for package resolution failure

Made-with: Cursor
2026-03-04 09:21:54 +01:00
Félix Malfait af9e551c74 fix: update snapshot for feature-flag-disabled test
Error code changed from BAD_USER_INPUT to FORBIDDEN.

Made-with: Cursor
2026-03-03 22:45:10 +01:00
Félix Malfait 0e3c1a3ce0 fix: throw error when UUID-based registration not found in installMarketplaceApp
When universalIdentifier is a UUID and no matching registration exists,
throw instead of falling through to create a new npm registration.

Made-with: Cursor
2026-03-03 21:36:26 +01:00
Félix Malfait 8981534805 fix: handle nullable description in application table and detail tab
Made-with: Cursor
2026-03-03 21:16:03 +01:00
Félix MalfaitandGitHub df0700bd9d Merge branch 'main' into feat/app-distribution-npm-tarball 2026-03-03 21:05:21 +01:00
Félix Malfait 07bec1310b graphql gen 2026-03-03 21:04:48 +01:00
Félix Malfait 61d98598d7 fix: use REL_ prefix for OneToOne unique constraint name
Made-with: Cursor
2026-03-03 18:36:07 +01:00
Félix Malfait 34ae2bc41e Fix 2026-03-03 18:03:08 +01:00
Félix Malfait e584a2d593 fix: extract response.status to variable for lingui i18n rule
Made-with: Cursor
2026-03-03 17:37:02 +01:00
Félix Malfait 800cfe409f fix: wrap upload error string with i18n t`` macro
Made-with: Cursor
2026-03-03 17:25:21 +01:00
Félix Malfait ff6d0c13d0 fix: add missing SDK command files and fix prettier formatting
- Create app-pack.ts and app-push.ts CLI commands (lost during merge)
- Remove duplicate app:build command registration in app-command.ts
- Fix Prettier formatting in server integration test files
- Fix Prettier formatting in app-registration-upload controller

Made-with: Cursor
2026-03-03 17:11:53 +01:00
Félix MalfaitandGitHub 97e45d0aed Merge branch 'main' into feat/app-distribution-npm-tarball 2026-03-03 16:52:49 +01:00
Félix Malfait d2ebe3edb2 feat: add npm and tarball app distribution with upgrade mechanism
Implement a comprehensive app distribution system supporting both public
(npm registry) and private (tarball upload) installation channels.

Backend:
- Add AppRegistrationSourceType enum (npm, tarball, none) to track app origin
- Add AppPackageResolverService for resolving packages from npm or tarball sources
- Add ApplicationInstallService with PostgreSQL advisory locks for safe installs
- Add AppUpgradeService with version checking and rollback support
- Add tarball upload REST endpoint with secure extraction (path traversal protection)
- Add marketplace catalog sync cron job (hourly) from hardcoded catalog index
- Add app version check cron job (every 6 hours) to detect available updates
- Disable yarn lifecycle scripts (enableScripts: false) to prevent RCE via postinstall
- Add database migration for sourceType, sourcePackage, latestAvailableVersion fields

Frontend:
- Add "Install from npm" modal for manual package installation
- Add "Upload tarball" modal for direct .tar.gz uploads
- Add upgrade mutation and version container with upgrade button
- Add blue "Update" badge on installed apps table when newer version available
- Fetch application registrations to compare installed vs latest versions
- Migrate styled components from Emotion to Linaria (matching main migration)
- Remove redundant sourcePackage mutation argument (derived from universalIdentifier)

Testing:
- Add integration tests for app distribution (install, upgrade, tarball upload)
- Add integration tests for marketplace catalog sync

Made-with: Cursor
2026-03-03 16:45:29 +01:00
203 changed files with 4583 additions and 1059 deletions
@@ -1,6 +1,6 @@
{
"name": "hello-world",
"version": "0.1.0",
"name": "@twentyhq/hello-world",
"version": "0.2.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
+17 -17
View File
@@ -1591,6 +1591,22 @@ __metadata:
languageName: node
linkType: hard
"@twentyhq/hello-world@workspace:.":
version: 0.0.0-use.local
resolution: "@twentyhq/hello-world@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
"@types/react": "npm:^18.2.0"
eslint: "npm:^9.32.0"
react: "npm:^18.2.0"
twenty-sdk: "npm:0.6.3"
typescript: "npm:^5.9.3"
typescript-eslint: "npm:^8.50.0"
vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^3.1.1"
languageName: unknown
linkType: soft
"@types/chai@npm:^5.2.2":
version: 5.2.3
resolution: "@types/chai@npm:5.2.3"
@@ -1604,7 +1620,7 @@ __metadata:
"@types/deep-eql@npm:*":
version: 4.0.2
resolution: "@types/deep-eql@npm:4.0.2"
checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
checksum: 10c0-bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
languageName: node
linkType: hard
@@ -4676,22 +4692,6 @@ __metadata:
languageName: node
linkType: hard
"hello-world@workspace:.":
version: 0.0.0-use.local
resolution: "hello-world@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
"@types/react": "npm:^18.2.0"
eslint: "npm:^9.32.0"
react: "npm:^18.2.0"
twenty-sdk: "npm:0.6.3"
typescript: "npm:^5.9.3"
typescript-eslint: "npm:^8.50.0"
vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^3.1.1"
languageName: unknown
linkType: soft
"hoist-non-react-statics@npm:^3.3.1":
version: 3.3.2
resolution: "hoist-non-react-statics@npm:3.3.2"
@@ -315,6 +315,12 @@ export type ApiKeyToken = {
token: Scalars['String'];
};
export enum AppRegistrationSourceType {
LOCAL = 'LOCAL',
NPM = 'NPM',
TARBALL = 'TARBALL'
}
export type AppToken = {
__typename?: 'AppToken';
createdAt: Scalars['DateTime'];
@@ -327,12 +333,14 @@ export type AppToken = {
export type Application = {
__typename?: 'Application';
agents: Array<Agent>;
applicationRegistration?: Maybe<ApplicationRegistrationSummary>;
applicationRegistrationId?: Maybe<Scalars['UUID']>;
applicationVariables: Array<ApplicationVariable>;
availablePackages: Scalars['JSON'];
canBeUninstalled: Scalars['Boolean'];
defaultLogicFunctionRole?: Maybe<Role>;
defaultRoleId?: Maybe<Scalars['String']>;
description: Scalars['String'];
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
logicFunctions: Array<LogicFunction>;
name: Scalars['String'];
@@ -341,7 +349,7 @@ export type Application = {
packageJsonFileId?: Maybe<Scalars['UUID']>;
settingsCustomTabFrontComponentId?: Maybe<Scalars['UUID']>;
universalIdentifier: Scalars['String'];
version: Scalars['String'];
version?: Maybe<Scalars['String']>;
yarnLockChecksum?: Maybe<Scalars['String']>;
yarnLockFileId?: Maybe<Scalars['UUID']>;
};
@@ -352,11 +360,15 @@ export type ApplicationRegistration = {
createdAt: Scalars['DateTime'];
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
isFeatured: Scalars['Boolean'];
latestAvailableVersion?: Maybe<Scalars['String']>;
logoUrl?: Maybe<Scalars['String']>;
name: Scalars['String'];
oAuthClientId: Scalars['String'];
oAuthRedirectUris: Array<Scalars['String']>;
oAuthScopes: Array<Scalars['String']>;
sourcePackage?: Maybe<Scalars['String']>;
sourceType: AppRegistrationSourceType;
termsUrl?: Maybe<Scalars['String']>;
universalIdentifier: Scalars['String'];
updatedAt: Scalars['DateTime'];
@@ -370,6 +382,13 @@ export type ApplicationRegistrationStats = {
versionDistribution: Array<VersionDistributionEntry>;
};
export type ApplicationRegistrationSummary = {
__typename?: 'ApplicationRegistrationSummary';
id: Scalars['UUID'];
latestAvailableVersion?: Maybe<Scalars['String']>;
sourceType: AppRegistrationSourceType;
};
export type ApplicationRegistrationVariable = {
__typename?: 'ApplicationRegistrationVariable';
createdAt: Scalars['DateTime'];
@@ -1774,6 +1793,7 @@ export type File = {
export enum FileFolder {
AgentChat = 'AgentChat',
AppTarball = 'AppTarball',
Attachment = 'Attachment',
BuiltFrontComponent = 'BuiltFrontComponent',
BuiltLogicFunction = 'BuiltLogicFunction',
@@ -2220,6 +2240,7 @@ export type MarketplaceApp = {
objects: Array<MarketplaceAppObject>;
providers: Array<Scalars['String']>;
screenshots: Array<Scalars['String']>;
sourcePackage?: Maybe<Scalars['String']>;
termsUrl?: Maybe<Scalars['String']>;
version: Scalars['String'];
websiteUrl?: Maybe<Scalars['String']>;
@@ -2438,6 +2459,7 @@ export type Mutation = {
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning;
installApplication: Scalars['Boolean'];
installMarketplaceApp: Scalars['Boolean'];
installNpmApp: Scalars['Boolean'];
removeQueryFromEventStream: Scalars['Boolean'];
removeRoleFromAgent: Scalars['Boolean'];
renewApplicationToken: ApplicationTokenPair;
@@ -2496,6 +2518,8 @@ export type Mutation = {
updateWorkspace: Workspace;
updateWorkspaceFeatureFlag: Scalars['Boolean'];
updateWorkspaceMemberRole: WorkspaceMember;
upgradeApplication: Scalars['Boolean'];
uploadAppTarball: ApplicationRegistration;
uploadApplicationFile: File;
/** @deprecated Use uploadFilesFieldFile instead */
uploadFile: SignedFile;
@@ -3039,6 +3063,18 @@ export type MutationInstallApplicationArgs = {
};
export type MutationInstallMarketplaceAppArgs = {
universalIdentifier: Scalars['String'];
version?: InputMaybe<Scalars['String']>;
};
export type MutationInstallNpmAppArgs = {
packageName: Scalars['String'];
version?: InputMaybe<Scalars['String']>;
};
export type MutationRemoveQueryFromEventStreamArgs = {
input: RemoveQueryFromEventStreamInput;
};
@@ -3344,6 +3380,18 @@ export type MutationUpdateWorkspaceMemberRoleArgs = {
};
export type MutationUpgradeApplicationArgs = {
appRegistrationId: Scalars['String'];
targetVersion: Scalars['String'];
};
export type MutationUploadAppTarballArgs = {
file: Scalars['Upload'];
universalIdentifier?: InputMaybe<Scalars['String']>;
};
export type MutationUploadApplicationFileArgs = {
applicationUniversalIdentifier: Scalars['String'];
file: Scalars['Upload'];
@@ -5801,19 +5849,19 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{
export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean };
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> };
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, universalIdentifier: string, applicationRegistrationId?: string | null, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationRegistration?: { __typename?: 'ApplicationRegistrationSummary', id: string, latestAvailableVersion?: string | null, sourceType: AppRegistrationSourceType } | null, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> };
export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description: string, version: string }> };
export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, applicationRegistrationId?: string | null, applicationRegistration?: { __typename?: 'ApplicationRegistrationSummary', id: string, latestAvailableVersion?: string | null, sourceType: AppRegistrationSourceType } | null }> };
export type FindOneApplicationQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> } };
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description?: string | null, version?: string | null, universalIdentifier: string, applicationRegistrationId?: string | null, canBeUninstalled: boolean, defaultRoleId?: string | null, settingsCustomTabFrontComponentId?: string | null, availablePackages: any, applicationRegistration?: { __typename?: 'ApplicationRegistrationSummary', id: string, latestAvailableVersion?: string | null, sourceType: AppRegistrationSourceType } | null, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, universalIdentifier: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, logicFunctions: Array<{ __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }> } };
export type UploadFileMutationVariables = Exact<{
file: Scalars['Upload'];
@@ -6238,12 +6286,44 @@ export type GetLogicFunctionSourceCodeQueryVariables = Exact<{
export type GetLogicFunctionSourceCodeQuery = { __typename?: 'Query', getLogicFunctionSourceCode?: string | null };
export type MarketplaceAppFieldsFragment = { __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array<string>, aboutDescription: string, providers: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array<string>, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | null };
export type MarketplaceAppFieldsFragment = { __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array<string>, aboutDescription: string, providers: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, sourcePackage?: string | null, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array<string>, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | null };
export type InstallMarketplaceAppMutationVariables = Exact<{
universalIdentifier: Scalars['String'];
version?: InputMaybe<Scalars['String']>;
}>;
export type InstallMarketplaceAppMutation = { __typename?: 'Mutation', installMarketplaceApp: boolean };
export type InstallNpmAppMutationVariables = Exact<{
packageName: Scalars['String'];
version?: InputMaybe<Scalars['String']>;
}>;
export type InstallNpmAppMutation = { __typename?: 'Mutation', installNpmApp: boolean };
export type UpgradeApplicationMutationVariables = Exact<{
appRegistrationId: Scalars['String'];
targetVersion: Scalars['String'];
}>;
export type UpgradeApplicationMutation = { __typename?: 'Mutation', upgradeApplication: boolean };
export type UploadAppTarballMutationVariables = Exact<{
file: Scalars['Upload'];
universalIdentifier?: InputMaybe<Scalars['String']>;
}>;
export type UploadAppTarballMutation = { __typename?: 'Mutation', uploadAppTarball: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string } };
export type FindManyMarketplaceAppsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManyMarketplaceAppsQuery = { __typename?: 'Query', findManyMarketplaceApps: Array<{ __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array<string>, aboutDescription: string, providers: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array<string>, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | null }> };
export type FindManyMarketplaceAppsQuery = { __typename?: 'Query', findManyMarketplaceApps: Array<{ __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array<string>, aboutDescription: string, providers: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, sourcePackage?: string | null, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array<string>, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | null }> };
export type NavigationMenuItemFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, icon?: string | null, color?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string };
@@ -6530,7 +6610,7 @@ export type GetSystemHealthStatusQueryVariables = Exact<{ [key: string]: never;
export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', services: Array<{ __typename?: 'SystemHealthService', id: HealthIndicatorId, label: string, status: AdminPanelHealthServiceStatus }> } };
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string };
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: AppRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string };
export type DeleteApplicationRegistrationMutationVariables = Exact<{
id: Scalars['String'];
@@ -6551,7 +6631,7 @@ export type UpdateApplicationRegistrationMutationVariables = Exact<{
}>;
export type UpdateApplicationRegistrationMutation = { __typename?: 'Mutation', updateApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string } };
export type UpdateApplicationRegistrationMutation = { __typename?: 'Mutation', updateApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: AppRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string } };
export type UpdateApplicationRegistrationVariableMutationVariables = Exact<{
input: UpdateApplicationRegistrationVariableInput;
@@ -6584,14 +6664,14 @@ export type FindApplicationRegistrationVariablesQuery = { __typename?: 'Query',
export type FindManyApplicationRegistrationsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManyApplicationRegistrationsQuery = { __typename?: 'Query', findManyApplicationRegistrations: Array<{ __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string }> };
export type FindManyApplicationRegistrationsQuery = { __typename?: 'Query', findManyApplicationRegistrations: Array<{ __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: AppRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string }> };
export type FindOneApplicationRegistrationQueryVariables = Exact<{
id: Scalars['String'];
}>;
export type FindOneApplicationRegistrationQuery = { __typename?: 'Query', findOneApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string } };
export type FindOneApplicationRegistrationQuery = { __typename?: 'Query', findOneApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, description?: string | null, logoUrl?: string | null, author?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: AppRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, websiteUrl?: string | null, termsUrl?: string | null, createdAt: string, updatedAt: string } };
export type UninstallApplicationMutationVariables = Exact<{
universalIdentifier: Scalars['String'];
@@ -7502,6 +7582,12 @@ export const ApplicationFieldsFragmentDoc = gql`
description
version
universalIdentifier
applicationRegistrationId
applicationRegistration {
id
latestAvailableVersion
sourceType
}
canBeUninstalled
defaultRoleId
settingsCustomTabFrontComponentId
@@ -7870,6 +7956,7 @@ export const MarketplaceAppFieldsFragmentDoc = gql`
name
description
}
sourcePackage
defaultRole {
id
label
@@ -7936,6 +8023,9 @@ export const ApplicationRegistrationFragmentFragmentDoc = gql`
oAuthClientId
oAuthRedirectUris
oAuthScopes
sourceType
sourcePackage
latestAvailableVersion
websiteUrl
termsUrl
createdAt
@@ -9325,6 +9415,12 @@ export const FindManyApplicationsDocument = gql`
name
description
version
applicationRegistrationId
applicationRegistration {
id
latestAvailableVersion
sourceType
}
}
}
`;
@@ -11513,6 +11609,144 @@ export function useGetLogicFunctionSourceCodeLazyQuery(baseOptions?: Apollo.Lazy
export type GetLogicFunctionSourceCodeQueryHookResult = ReturnType<typeof useGetLogicFunctionSourceCodeQuery>;
export type GetLogicFunctionSourceCodeLazyQueryHookResult = ReturnType<typeof useGetLogicFunctionSourceCodeLazyQuery>;
export type GetLogicFunctionSourceCodeQueryResult = Apollo.QueryResult<GetLogicFunctionSourceCodeQuery, GetLogicFunctionSourceCodeQueryVariables>;
export const InstallMarketplaceAppDocument = gql`
mutation InstallMarketplaceApp($universalIdentifier: String!, $version: String) {
installMarketplaceApp(
universalIdentifier: $universalIdentifier
version: $version
)
}
`;
export type InstallMarketplaceAppMutationFn = Apollo.MutationFunction<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>;
/**
* __useInstallMarketplaceAppMutation__
*
* To run a mutation, you first call `useInstallMarketplaceAppMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useInstallMarketplaceAppMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [installMarketplaceAppMutation, { data, loading, error }] = useInstallMarketplaceAppMutation({
* variables: {
* universalIdentifier: // value for 'universalIdentifier'
* version: // value for 'version'
* },
* });
*/
export function useInstallMarketplaceAppMutation(baseOptions?: Apollo.MutationHookOptions<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>(InstallMarketplaceAppDocument, options);
}
export type InstallMarketplaceAppMutationHookResult = ReturnType<typeof useInstallMarketplaceAppMutation>;
export type InstallMarketplaceAppMutationResult = Apollo.MutationResult<InstallMarketplaceAppMutation>;
export type InstallMarketplaceAppMutationOptions = Apollo.BaseMutationOptions<InstallMarketplaceAppMutation, InstallMarketplaceAppMutationVariables>;
export const InstallNpmAppDocument = gql`
mutation InstallNpmApp($packageName: String!, $version: String) {
installNpmApp(packageName: $packageName, version: $version)
}
`;
export type InstallNpmAppMutationFn = Apollo.MutationFunction<InstallNpmAppMutation, InstallNpmAppMutationVariables>;
/**
* __useInstallNpmAppMutation__
*
* To run a mutation, you first call `useInstallNpmAppMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useInstallNpmAppMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [installNpmAppMutation, { data, loading, error }] = useInstallNpmAppMutation({
* variables: {
* packageName: // value for 'packageName'
* version: // value for 'version'
* },
* });
*/
export function useInstallNpmAppMutation(baseOptions?: Apollo.MutationHookOptions<InstallNpmAppMutation, InstallNpmAppMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<InstallNpmAppMutation, InstallNpmAppMutationVariables>(InstallNpmAppDocument, options);
}
export type InstallNpmAppMutationHookResult = ReturnType<typeof useInstallNpmAppMutation>;
export type InstallNpmAppMutationResult = Apollo.MutationResult<InstallNpmAppMutation>;
export type InstallNpmAppMutationOptions = Apollo.BaseMutationOptions<InstallNpmAppMutation, InstallNpmAppMutationVariables>;
export const UpgradeApplicationDocument = gql`
mutation UpgradeApplication($appRegistrationId: String!, $targetVersion: String!) {
upgradeApplication(
appRegistrationId: $appRegistrationId
targetVersion: $targetVersion
)
}
`;
export type UpgradeApplicationMutationFn = Apollo.MutationFunction<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>;
/**
* __useUpgradeApplicationMutation__
*
* To run a mutation, you first call `useUpgradeApplicationMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpgradeApplicationMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [upgradeApplicationMutation, { data, loading, error }] = useUpgradeApplicationMutation({
* variables: {
* appRegistrationId: // value for 'appRegistrationId'
* targetVersion: // value for 'targetVersion'
* },
* });
*/
export function useUpgradeApplicationMutation(baseOptions?: Apollo.MutationHookOptions<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>(UpgradeApplicationDocument, options);
}
export type UpgradeApplicationMutationHookResult = ReturnType<typeof useUpgradeApplicationMutation>;
export type UpgradeApplicationMutationResult = Apollo.MutationResult<UpgradeApplicationMutation>;
export type UpgradeApplicationMutationOptions = Apollo.BaseMutationOptions<UpgradeApplicationMutation, UpgradeApplicationMutationVariables>;
export const UploadAppTarballDocument = gql`
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
export type UploadAppTarballMutationFn = Apollo.MutationFunction<UploadAppTarballMutation, UploadAppTarballMutationVariables>;
/**
* __useUploadAppTarballMutation__
*
* To run a mutation, you first call `useUploadAppTarballMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUploadAppTarballMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [uploadAppTarballMutation, { data, loading, error }] = useUploadAppTarballMutation({
* variables: {
* file: // value for 'file'
* universalIdentifier: // value for 'universalIdentifier'
* },
* });
*/
export function useUploadAppTarballMutation(baseOptions?: Apollo.MutationHookOptions<UploadAppTarballMutation, UploadAppTarballMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UploadAppTarballMutation, UploadAppTarballMutationVariables>(UploadAppTarballDocument, options);
}
export type UploadAppTarballMutationHookResult = ReturnType<typeof useUploadAppTarballMutation>;
export type UploadAppTarballMutationResult = Apollo.MutationResult<UploadAppTarballMutation>;
export type UploadAppTarballMutationOptions = Apollo.BaseMutationOptions<UploadAppTarballMutation, UploadAppTarballMutationVariables>;
export const FindManyMarketplaceAppsDocument = gql`
query FindManyMarketplaceApps {
findManyMarketplaceApps {
@@ -13,6 +13,12 @@ export const APPLICATION_FRAGMENT = gql`
description
version
universalIdentifier
applicationRegistrationId
applicationRegistration {
id
latestAvailableVersion
sourceType
}
canBeUninstalled
defaultRoleId
settingsCustomTabFrontComponentId
@@ -7,6 +7,12 @@ export const FIND_MANY_APPLICATIONS = gql`
name
description
version
applicationRegistrationId
applicationRegistration {
id
latestAvailableVersion
sourceType
}
}
}
`;
@@ -49,6 +49,7 @@ export const MARKETPLACE_APP_FRAGMENT = gql`
name
description
}
sourcePackage
defaultRole {
id
label
@@ -1,7 +1,13 @@
import gql from 'graphql-tag';
export const INSTALL_MARKETPLACE_APP = gql`
mutation InstallMarketplaceApp() {
installMarketplaceApp()
mutation InstallMarketplaceApp(
$universalIdentifier: String!
$version: String
) {
installMarketplaceApp(
universalIdentifier: $universalIdentifier
version: $version
)
}
`;
@@ -0,0 +1,7 @@
import gql from 'graphql-tag';
export const INSTALL_NPM_APP = gql`
mutation InstallNpmApp($packageName: String!, $version: String) {
installNpmApp(packageName: $packageName, version: $version)
}
`;
@@ -0,0 +1,13 @@
import gql from 'graphql-tag';
export const UPGRADE_APPLICATION = gql`
mutation UpgradeApplication(
$appRegistrationId: String!
$targetVersion: String!
) {
upgradeApplication(
appRegistrationId: $appRegistrationId
targetVersion: $targetVersion
)
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const UPLOAD_APP_TARBALL = gql`
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
@@ -0,0 +1,41 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useInstallApp = <TVariables extends Record<string, unknown>>(
mutationFn: (options: {
variables: TVariables;
}) => Promise<{ data?: Record<string, unknown> | null }>,
) => {
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [isInstalling, setIsInstalling] = useState(false);
const install = async (variables: TVariables) => {
setIsInstalling(true);
try {
const result = await mutationFn({ variables });
if (isDefined(result.data)) {
enqueueSuccessSnackBar({
message: t`Application installed successfully.`,
});
return true;
}
return false;
} catch {
enqueueErrorSnackBar({
message: t`Failed to install the application.`,
});
return false;
} finally {
setIsInstalling(false);
}
};
return { install, isInstalling };
};
@@ -1,9 +1,11 @@
export const useInstallMarketplaceApp = () => {
const install = async () => {
return true;
};
import { useInstallApp } from '~/modules/marketplace/hooks/useInstallApp';
import { useInstallMarketplaceAppMutation } from '~/generated-metadata/graphql';
return {
install,
};
export const useInstallMarketplaceApp = () => {
const [installMarketplaceAppMutation] = useInstallMarketplaceAppMutation();
return useInstallApp<{
universalIdentifier: string;
version?: string;
}>(installMarketplaceAppMutation);
};
@@ -0,0 +1,13 @@
import { useMutation } from '@apollo/client';
import { useInstallApp } from '~/modules/marketplace/hooks/useInstallApp';
import { INSTALL_NPM_APP } from '~/modules/marketplace/graphql/mutations/installNpmApp';
export const useInstallNpmApp = () => {
const [installNpmAppMutation] = useMutation(INSTALL_NPM_APP);
return useInstallApp<{
packageName: string;
version?: string;
}>(installNpmAppMutation);
};
@@ -0,0 +1,44 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useUpgradeApplicationMutation } from '~/generated-metadata/graphql';
export const useUpgradeApplication = () => {
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [upgradeApplicationMutation] = useUpgradeApplicationMutation();
const [isUpgrading, setIsUpgrading] = useState(false);
const upgrade = async (params: {
appRegistrationId: string;
targetVersion: string;
}) => {
setIsUpgrading(true);
try {
const result = await upgradeApplicationMutation({
variables: params,
});
if (isDefined(result.data)) {
enqueueSuccessSnackBar({
message: t`Application upgraded successfully.`,
});
return true;
}
return false;
} catch {
enqueueErrorSnackBar({
message: t`Failed to upgrade the application.`,
});
return false;
} finally {
setIsUpgrading(false);
}
};
return { upgrade, isUpgrading };
};
@@ -0,0 +1,54 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { UPLOAD_APP_TARBALL } from '~/modules/marketplace/graphql/mutations/uploadAppTarball';
type UploadResult =
| {
success: true;
universalIdentifier: string;
}
| {
success: false;
};
export const useUploadAppTarball = () => {
const [uploadAppTarball] = useMutation(UPLOAD_APP_TARBALL);
const { enqueueErrorSnackBar } = useSnackBar();
const [isUploading, setIsUploading] = useState(false);
const upload = async (file: File): Promise<UploadResult> => {
setIsUploading(true);
try {
const result = await uploadAppTarball({
variables: { file },
});
const registration = result.data?.uploadAppTarball;
if (!isDefined(registration?.universalIdentifier)) {
enqueueErrorSnackBar({ message: t`Upload failed.` });
return { success: false };
}
return {
success: true,
universalIdentifier: registration.universalIdentifier,
};
} catch {
enqueueErrorSnackBar({
message: t`Failed to upload tarball.`,
});
return { success: false };
} finally {
setIsUploading(false);
}
};
return { upload, isUploading };
};
@@ -11,6 +11,9 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
oAuthClientId
oAuthRedirectUris
oAuthScopes
sourceType
sourcePackage
latestAvailableVersion
websiteUrl
termsUrl
createdAt
@@ -28,7 +28,7 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { useMarketplaceApps } from '~/pages/settings/applications/hooks/useMarketplaceApps';
import { useMarketplaceApps } from '~/modules/marketplace/hooks/useMarketplaceApps';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { SettingsAvailableApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsAvailableApplicationDetailContentTab';
@@ -248,7 +248,7 @@ export const SettingsAvailableApplicationDetails = () => {
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
const { data: marketplaceApps } = useMarketplaceApps();
const { install } = useInstallMarketplaceApp();
const { install, isInstalling } = useInstallMarketplaceApp();
const canInstallMarketplaceApps = useHasPermissionFlag(
PermissionFlagType.MARKETPLACE_APPS,
);
@@ -259,7 +259,9 @@ export const SettingsAvailableApplicationDetails = () => {
const handleInstall = async () => {
if (isDefined(application)) {
await install();
await install({
universalIdentifier: application.id,
});
}
};
@@ -460,10 +462,11 @@ export const SettingsAvailableApplicationDetails = () => {
{canInstallMarketplaceApps && (
<Button
Icon={IconDownload}
title={t`Install`}
title={isInstalling ? t`Installing...` : t`Install`}
variant="primary"
accent="blue"
onClick={handleInstall}
disabled={isInstalling}
/>
)}
</StyledHeader>
@@ -0,0 +1,61 @@
import { styled } from '@linaria/react';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { Button } from 'twenty-ui/input';
import {
type ModalOverlay,
type ModalPadding,
type ModalSize,
Section,
} from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type StyledAppModalProps = React.PropsWithChildren<{
modalId: string;
isClosable?: boolean;
onClose?: () => void;
size?: ModalSize;
padding?: ModalPadding;
overlay?: ModalOverlay;
dataGloballyPreventClickOutside?: boolean;
}>;
export const StyledAppModal = ({
modalId,
children,
isClosable = false,
onClose,
size,
padding,
overlay,
dataGloballyPreventClickOutside,
}: StyledAppModalProps) => (
<ModalStatefulWrapper
modalInstanceId={modalId}
isClosable={isClosable as true}
onClose={onClose}
size={size}
padding={padding}
overlay={overlay}
smallBorderRadius
narrowWidth
autoHeight
dataGloballyPreventClickOutside={dataGloballyPreventClickOutside}
>
{children}
</ModalStatefulWrapper>
);
export const StyledAppModalButton = styled(Button)`
box-sizing: border-box;
justify-content: center;
margin-top: ${themeCssVariables.spacing[2]};
`;
export const StyledAppModalTitle = styled.div`
text-align: center;
`;
export const StyledAppModalSection = styled(Section)`
margin-bottom: ${themeCssVariables.spacing[6]};
`;
@@ -1,15 +1,18 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { t } from '@lingui/core/macro';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Tag } from 'twenty-ui/components';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
export type SettingsApplicationTableRowProps = {
action: ReactNode;
application: ApplicationWithoutRelation;
hasUpdate?: boolean;
link?: string;
};
@@ -29,9 +32,17 @@ const StyledActionTableCell = styled(TableCell)`
padding-right: ${themeCssVariables.spacing[2]};
`;
const StyledDescriptionCell = styled(TableCell)`
display: flex;
align-items: center;
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
export const SettingsApplicationTableRow = ({
action,
application,
hasUpdate,
link,
}: SettingsApplicationTableRowProps) => {
return (
@@ -39,9 +50,12 @@ export const SettingsApplicationTableRow = ({
<StyledNameTableCell>
<OverflowingTextWithTooltip text={application.name} />
</StyledNameTableCell>
<TableCell>
<StyledDescriptionCell>
<OverflowingTextWithTooltip text={application.description} />
</TableCell>
{hasUpdate === true && (
<Tag color="blue" text={t`Update`} weight="medium" />
)}
</StyledDescriptionCell>
<StyledActionTableCell>{action}</StyledActionTableCell>
</StyledApplicationTableRow>
);
@@ -1,24 +1,55 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconCircleDot, IconStatusChange } from 'twenty-ui/display';
import type { Application } from '~/generated-metadata/graphql';
import { IconCircleDot, IconStatusChange, IconUpload } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
AppRegistrationSourceType,
type Application,
} from '~/generated-metadata/graphql';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
export const SettingsApplicationVersionContainer = ({
application,
latestAvailableVersion,
appRegistrationId,
}: {
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
latestAvailableVersion?: string | null;
appRegistrationId?: string | null;
}) => {
const loading = !isDefined(application);
const currentVersion = application?.version;
// TODO fetch latestVersion of the application
// if published on twenty public application registry
const latestVersion = currentVersion;
const sourceType = application?.applicationRegistration?.sourceType;
const isNpmApp = sourceType === AppRegistrationSourceType.NPM;
const latestVersion = isNpmApp
? (latestAvailableVersion ?? currentVersion)
: currentVersion;
const hasUpdate =
isNpmApp &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
const { upgrade, isUpgrading } = useUpgradeApplication();
const handleUpgrade = async () => {
if (!isDefined(appRegistrationId) || !isDefined(latestAvailableVersion)) {
return;
}
await upgrade({
appRegistrationId,
targetVersion: latestAvailableVersion,
});
};
const versionItems = [
{
@@ -32,24 +63,44 @@ export const SettingsApplicationVersionContainer = ({
/>
),
},
{
Icon: IconStatusChange,
label: t`Latest version`,
value: (
<SettingsAdminVersionDisplay
version={latestVersion}
loading={loading}
noVersionMessage={t`No latest version found`}
/>
),
},
...(isNpmApp
? [
{
Icon: IconStatusChange,
label: t`Latest version`,
value: (
<SettingsAdminVersionDisplay
version={latestVersion}
loading={loading}
noVersionMessage={t`No latest version found`}
/>
),
},
]
: []),
];
return (
<SettingsAdminTableCard
rounded
items={versionItems}
gridAutoColumns="3fr 8fr"
/>
<>
<SettingsAdminTableCard
rounded
items={versionItems}
gridAutoColumns="3fr 8fr"
/>
{hasUpdate && isDefined(appRegistrationId) && (
<Button
Icon={IconUpload}
title={
isUpgrading
? t`Upgrading...`
: t`Upgrade to ${latestAvailableVersion}`
}
variant="secondary"
accent="blue"
onClick={handleUpgrade}
disabled={isUpgrading}
/>
)}
</>
);
};
@@ -1,6 +1,6 @@
import { H2Title, IconChevronRight, IconSearch } from 'twenty-ui/display';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { useLingui } from '@lingui/react/macro';
import { Table } from '@/ui/layout/table/components/Table';
@@ -11,10 +11,12 @@ import {
} from '~/pages/settings/applications/components/SettingsApplicationTableRow';
import { useContext, useMemo, useState } from 'react';
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
import { Section } from 'twenty-ui/layout';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ThemeContext } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AppRegistrationSourceType } from '~/generated-metadata/graphql';
const StyledTable = styled(Table)`
margin-top: ${themeCssVariables.spacing[3]};
@@ -44,7 +46,7 @@ export const SettingsApplicationsTable = ({
return applications.filter(
(application) =>
application.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
application.description
(application.description ?? '')
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
@@ -70,21 +72,37 @@ export const SettingsApplicationsTable = ({
<TableHeader> {''}</TableHeader>
<TableHeader />
</StyledTableHeaderRow>
{filteredApplications.map((application) => (
<SettingsApplicationTableRow
key={application.id}
application={application}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
})}
/>
))}
{filteredApplications.map((application) => {
const isNpmApp =
application.applicationRegistration?.sourceType ===
AppRegistrationSourceType.NPM;
const latestVersion =
application.applicationRegistration?.latestAvailableVersion;
const hasUpdate =
isNpmApp &&
isDefined(latestVersion) &&
isDefined(application.version) &&
isNewerSemver(latestVersion, application.version);
return (
<SettingsApplicationTableRow
key={application.id}
application={application}
hasUpdate={hasUpdate}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {
applicationId: application.id,
})}
/>
);
})}
</StyledTable>
</Section>
);
@@ -0,0 +1,122 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useLingui } from '@lingui/react/macro';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useInstallNpmApp } from '~/modules/marketplace/hooks/useInstallNpmApp';
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
export const INSTALL_NPM_APP_MODAL_ID = 'install-npm-app-modal';
const StyledInputGroup = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
`;
export const SettingsInstallNpmAppModal = () => {
const { t } = useLingui();
const { closeModal } = useModal();
const { install, isInstalling } = useInstallNpmApp();
const { refetch } = useFindManyApplicationsQuery();
const [packageName, setPackageName] = useState('');
const [version, setVersion] = useState('');
const isValid = packageName.trim().length > 0;
const handleInstall = async () => {
if (!isValid) {
return;
}
const success = await install({
packageName: packageName.trim(),
version: version.trim() || undefined,
});
if (success) {
await refetch();
closeModal(INSTALL_NPM_APP_MODAL_ID);
setPackageName('');
setVersion('');
}
};
const handleCancel = () => {
closeModal(INSTALL_NPM_APP_MODAL_ID);
setPackageName('');
setVersion('');
};
return (
<StyledAppModal
modalId={INSTALL_NPM_APP_MODAL_ID}
isClosable={true}
padding="large"
dataGloballyPreventClickOutside
>
<StyledAppModalTitle>
<H1Title
title={t`Install from npm`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{t`Enter the npm package name of the application you want to install.`}
</StyledAppModalSection>
<Section>
<StyledInputGroup>
<SettingsTextInput
instanceId="npm-package-name-input"
value={packageName}
onChange={setPackageName}
placeholder={t`e.g. @twentyhq/hello-world`}
fullWidth
disableHotkeys
label={t`Package name`}
autoFocusOnMount
/>
<SettingsTextInput
instanceId="npm-version-input"
value={version}
onChange={setVersion}
placeholder={t`latest`}
fullWidth
disableHotkeys
label={t`Version (optional)`}
/>
</StyledInputGroup>
</Section>
<StyledAppModalButton
onClick={handleCancel}
variant="secondary"
title={t`Cancel`}
fullWidth
/>
<StyledAppModalButton
onClick={handleInstall}
variant="secondary"
accent="blue"
title={t`Install`}
disabled={!isValid || isInstalling}
fullWidth
/>
</StyledAppModal>
);
};
@@ -0,0 +1,111 @@
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import { useUploadAppTarball } from '~/modules/marketplace/hooks/useUploadAppTarball';
import { useInstallMarketplaceApp } from '~/modules/marketplace/hooks/useInstallMarketplaceApp';
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
export const UPLOAD_TARBALL_MODAL_ID = 'upload-tarball-modal';
const StyledFileInput = styled.input`
display: none;
`;
export const SettingsUploadTarballModal = () => {
const { t } = useLingui();
const { closeModal } = useModal();
const { upload, isUploading } = useUploadAppTarball();
const { install } = useInstallMarketplaceApp();
const { refetch } = useFindManyApplicationsQuery();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!isDefined(file)) {
return;
}
const uploadResult = await upload(file);
if (uploadResult.success) {
const installResult = await install({
universalIdentifier: uploadResult.universalIdentifier,
});
if (installResult) {
await refetch();
closeModal(UPLOAD_TARBALL_MODAL_ID);
}
}
if (isDefined(fileInputRef.current)) {
fileInputRef.current.value = '';
}
};
const handleCancel = () => {
closeModal(UPLOAD_TARBALL_MODAL_ID);
};
return (
<StyledAppModal
modalId={UPLOAD_TARBALL_MODAL_ID}
isClosable={true}
padding="large"
dataGloballyPreventClickOutside
>
<StyledAppModalTitle>
<H1Title
title={t`Upload tarball`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{t`Select a .tar.gz application package to upload and install.`}
</StyledAppModalSection>
<StyledFileInput
ref={fileInputRef}
type="file"
accept=".tar.gz,.tgz"
onChange={handleFileChange}
/>
<StyledAppModalButton
onClick={handleCancel}
variant="secondary"
title={t`Cancel`}
fullWidth
/>
<StyledAppModalButton
onClick={handleSelectFile}
variant="secondary"
accent="blue"
title={t`Choose file`}
disabled={isUploading}
fullWidth
/>
</StyledAppModal>
);
};
@@ -36,6 +36,11 @@ export const SettingsApplicationDetailAboutTab = ({
const navigate = useNavigateSettings();
const registrationId = application?.applicationRegistrationId;
const latestAvailableVersion =
application?.applicationRegistration?.latestAvailableVersion ?? null;
if (!isDefined(application)) {
return null;
}
@@ -80,7 +85,7 @@ export const SettingsApplicationDetailAboutTab = ({
/>
<SettingsTextInput
instanceId={`application-description-${id}`}
value={description}
value={description ?? undefined}
disabled
fullWidth
/>
@@ -90,7 +95,11 @@ export const SettingsApplicationDetailAboutTab = ({
title={t`Version`}
description={t`Version of the application`}
/>
<SettingsApplicationVersionContainer application={application} />
<SettingsApplicationVersionContainer
application={application}
latestAvailableVersion={latestAvailableVersion}
appRegistrationId={registrationId}
/>
</Section>
{application.canBeUninstalled && (
<>
@@ -5,7 +5,7 @@ import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAvailableApplicationCard } from '~/pages/settings/applications/components/SettingsAvailableApplicationCard';
import { useMarketplaceApps } from '~/pages/settings/applications/hooks/useMarketplaceApps';
import { useMarketplaceApps } from '~/modules/marketplace/hooks/useMarketplaceApps';
const StyledSearchInputContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
@@ -1,14 +1,61 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconDownload, IconUpload } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
import {
SettingsInstallNpmAppModal,
INSTALL_NPM_APP_MODAL_ID,
} from '~/pages/settings/applications/components/SettingsInstallNpmAppModal';
import {
SettingsUploadTarballModal,
UPLOAD_TARBALL_MODAL_ID,
} from '~/pages/settings/applications/components/SettingsUploadTarballModal';
const StyledButtonGroup = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[4]};
`;
export const SettingsApplicationsInstalledTab = () => {
const { t } = useLingui();
const { data } = useFindManyApplicationsQuery();
const { openModal } = useModal();
const applications = data?.findManyApplications ?? [];
if (applications.length === 0) {
return null;
}
return (
<>
<Section>
<StyledButtonGroup>
<Button
Icon={IconDownload}
title={t`Install from npm`}
variant="secondary"
size="small"
onClick={() => openModal(INSTALL_NPM_APP_MODAL_ID)}
/>
<Button
Icon={IconUpload}
title={t`Upload tarball`}
variant="secondary"
size="small"
onClick={() => openModal(UPLOAD_TARBALL_MODAL_ID)}
/>
</StyledButtonGroup>
</Section>
return <SettingsApplicationsTable applications={applications} />;
{applications.length > 0 && (
<SettingsApplicationsTable applications={applications} />
)}
<SettingsInstallNpmAppModal />
<SettingsUploadTarballModal />
</>
);
};
@@ -2,5 +2,10 @@ import { type Application } from '~/generated-metadata/graphql';
export type ApplicationWithoutRelation = Pick<
Application,
'id' | 'name' | 'description'
| 'id'
| 'name'
| 'description'
| 'version'
| 'applicationRegistrationId'
| 'applicationRegistration'
>;
@@ -0,0 +1,15 @@
// Returns true if versionA is strictly greater than versionB (major.minor.patch)
export const isNewerSemver = (versionA: string, versionB: string): boolean => {
const partsA = versionA.split('.').map(Number);
const partsB = versionB.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const a = partsA[i] ?? 0;
const b = partsB[i] ?? 0;
if (a > b) return true;
if (a < b) return false;
}
return false;
};
+1
View File
@@ -79,6 +79,7 @@
"dotenv": "^16.4.0",
"esbuild": "^0.25.0",
"fast-glob": "^3.3.0",
"form-data": "^4.0.5",
"fs-extra": "^11.2.0",
"graphql": "^16.8.1",
"graphql-sse": "^2.5.4",
@@ -3,6 +3,8 @@ import chalk from 'chalk';
import type { Command } from 'commander';
import { AppBuildCommand } from './app/app-build';
import { AppDevCommand } from './app/app-dev';
import { AppPackCommand } from './app/app-pack';
import { AppPushCommand } from './app/app-push';
import { AppTypecheckCommand } from './app/app-typecheck';
import { AppUninstallCommand } from './app/app-uninstall';
import { AuthListCommand } from './auth/auth-list';
@@ -63,6 +65,8 @@ export const registerCommands = (program: Command): void => {
// App commands
const buildCommand = new AppBuildCommand();
const devCommand = new AppDevCommand();
const packCommand = new AppPackCommand();
const pushCommand = new AppPushCommand();
const typecheckCommand = new AppTypecheckCommand();
const uninstallCommand = new AppUninstallCommand();
const addCommand = new EntityAddCommand();
@@ -112,6 +116,28 @@ export const registerCommands = (program: Command): void => {
}
});
program
.command('app:pack [appPath]')
.description('Build and pack the application into a .tgz tarball')
.action(async (appPath) => {
await packCommand.execute({ appPath: formatPath(appPath) });
});
program
.command('app:push [appPath]')
.description(
'Build, upload, and install a local application on a Twenty server (for air-gapped/dev deployments)',
)
.option('--server <url>', 'Twenty server URL')
.option('--token <token>', 'Auth token for the server')
.action(async (appPath, options) => {
await pushCommand.execute({
appPath: formatPath(appPath),
server: options.server,
token: options.token,
});
});
program
.command('entity:add [entityType]')
.option('--path <path>', 'Path in which the entity should be created.')
@@ -0,0 +1,40 @@
import { appBuild } from '@/cli/public-operations/app-build';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import { execSync } from 'child_process';
import path from 'path';
export type AppPackCommandOptions = {
appPath?: string;
};
export class AppPackCommand {
async execute(options: AppPackCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building and packing application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const buildResult = await appBuild({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!buildResult.success) {
console.error(chalk.red(buildResult.error.message));
process.exit(1);
}
console.log(chalk.gray('Packing tarball...'));
const outputDir = path.join(appPath, '.twenty', 'output');
execSync('npm pack --pack-destination .', {
cwd: outputDir,
stdio: 'inherit',
});
console.log(chalk.green('✓ Application packed successfully'));
}
}
@@ -0,0 +1,81 @@
import { appBuild } from '@/cli/public-operations/app-build';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { ApiService } from '@/cli/utilities/api/api-service';
import chalk from 'chalk';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
export type AppPushCommandOptions = {
appPath?: string;
server?: string;
token?: string;
};
export class AppPushCommand {
async execute(options: AppPushCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building, packing, and pushing application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const buildResult = await appBuild({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!buildResult.success) {
console.error(chalk.red(buildResult.error.message));
process.exit(1);
}
console.log(chalk.gray('Packing tarball...'));
const outputDir = path.join(appPath, '.twenty', 'output');
// TODO: Extract pack logic into a shared public operation (like appBuild)
// so app:pack and app:push share the same implementation.
const packOutput = execSync('npm pack --pack-destination .', {
cwd: outputDir,
encoding: 'utf-8',
}).trim();
const tarballName = packOutput.split('\n').pop()!;
const tarballPath = path.join(outputDir, tarballName);
if (!fs.existsSync(tarballPath)) {
console.error(chalk.red(`Tarball not found at ${tarballPath}`));
process.exit(1);
}
console.log(chalk.gray(`Uploading ${path.basename(tarballPath)}...`));
const tarballBuffer = fs.readFileSync(tarballPath);
const apiService = new ApiService({
serverUrl: options.server,
token: options.token,
});
const uploadResult = await apiService.uploadAppTarball({ tarballBuffer });
if (!uploadResult.success) {
console.error(chalk.red(`Upload failed: ${uploadResult.error}`));
process.exit(1);
}
console.log(chalk.gray('Installing application...'));
const installResult = await apiService.installTarballApp({
universalIdentifier: uploadResult.data.universalIdentifier,
});
if (!installResult.success) {
console.error(chalk.red(`Install failed: ${installResult.error}`));
process.exit(1);
}
console.log(chalk.green('✓ Application pushed and installed successfully'));
}
}
@@ -14,18 +14,24 @@ export class ApiService {
private client: AxiosInstance;
private configService: ConfigService;
constructor(options?: { disableInterceptors: boolean }) {
const { disableInterceptors = false } = options || {};
constructor(options?: {
disableInterceptors?: boolean;
serverUrl?: string;
token?: string;
}) {
const { disableInterceptors = false, serverUrl, token } = options || {};
this.configService = new ConfigService();
this.client = axios.create();
this.client.interceptors.request.use(async (config) => {
const twentyConfig = await this.configService.getConfig();
config.baseURL = twentyConfig.apiUrl;
config.baseURL = serverUrl ?? twentyConfig.apiUrl;
if (!config.headers.Authorization && twentyConfig.apiKey) {
config.headers.Authorization = `Bearer ${twentyConfig.apiKey}`;
const authToken = token ?? twentyConfig.apiKey;
if (!config.headers.Authorization && authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
}
return config;
@@ -794,6 +800,140 @@ export class ApiService {
);
}
// TODO: Migrate to MetadataClient once available
// (see https://github.com/twentyhq/core-team-issues/issues/2289)
async uploadAppTarball({
tarballBuffer,
universalIdentifier,
}: {
tarballBuffer: Buffer;
universalIdentifier?: string;
}): Promise<
ApiResponse<{
id: string;
universalIdentifier: string;
name: string;
}>
> {
try {
const mutation = `
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
const operations = JSON.stringify({
query: mutation,
variables: {
file: null,
universalIdentifier: universalIdentifier ?? null,
},
});
const map = JSON.stringify({
'0': ['variables.file'],
});
const formData = new FormData();
formData.append('operations', operations);
formData.append('map', map);
formData.append(
'0',
new Blob([new Uint8Array(tarballBuffer)], {
type: 'application/gzip',
}),
'app.tar.gz',
);
const response: AxiosResponse = await this.client.post(
'/metadata',
formData,
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || 'Failed to upload tarball',
};
}
return {
success: true,
data: response.data.data.uploadAppTarball,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
async installTarballApp({
universalIdentifier,
}: {
universalIdentifier: string;
}): Promise<ApiResponse<boolean>> {
try {
const mutation = `
mutation InstallMarketplaceApp($universalIdentifier: String!) {
installMarketplaceApp(universalIdentifier: $universalIdentifier)
}
`;
const response: AxiosResponse = await this.client.post(
'/metadata',
{
query: mutation,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message || 'Failed to install application',
};
}
return {
success: true,
data: response.data.data.installMarketplaceApp,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
async uploadFile({
filePath,
builtHandlerPath,
+7 -6
View File
@@ -37,15 +37,16 @@ export default defineConfig(() => {
}
const builtins = [
'path',
'child_process',
'crypto',
'fs',
'fs/promises',
'url',
'crypto',
'stream',
'util',
'os',
'module',
'os',
'path',
'stream',
'url',
'util',
];
if (builtins.includes(id)) {
+1
View File
@@ -53,3 +53,4 @@ const jestConfig = {
};
export default jestConfig;
+2
View File
@@ -172,6 +172,7 @@
"semver": "7.6.3",
"sharp": "0.32.6",
"stripe": "19.3.1",
"tar": "^7.5.9",
"temporal-polyfill": "^0.3.0",
"transliteration": "2.3.5",
"tsconfig-paths": "^4.2.0",
@@ -221,6 +222,7 @@
"@types/pluralize": "^0.0.33",
"@types/psl": "^1.1.3",
"@types/react": "^18.2.39",
"@types/tar": "^7.0.87",
"@types/unzipper": "^0",
"@yarnpkg/types": "^4.0.0",
"rimraf": "^5.0.5",
@@ -2,6 +2,8 @@ import { Logger } from '@nestjs/common';
import { Command, CommandRunner } from 'nest-commander';
import { AppVersionCheckCronCommand } from 'src/engine/core-modules/application/application-version-check/crons/commands/app-version-check.cron.command';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
@@ -52,6 +54,8 @@ export class CronRegisterAllCommand extends CommandRunner {
private readonly cleanOnboardingWorkspacesCronCommand: CleanOnboardingWorkspacesCronCommand,
private readonly trashCleanupCronCommand: TrashCleanupCronCommand,
private readonly eventLogCleanupCronCommand: EventLogCleanupCronCommand,
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
private readonly appVersionCheckCronCommand: AppVersionCheckCronCommand,
) {
super();
}
@@ -136,6 +140,14 @@ export class CronRegisterAllCommand extends CommandRunner {
name: 'EventLogCleanup',
command: this.eventLogCleanupCronCommand,
},
{
name: 'MarketplaceCatalogSync',
command: this.marketplaceCatalogSyncCronCommand,
},
{
name: 'AppVersionCheck',
command: this.appVersionCheckCronCommand,
},
];
let successCount = 0;
@@ -9,6 +9,8 @@ import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-versi
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
import { AppVersionCheckModule } from 'src/engine/core-modules/application/application-version-check/application-version-check.module';
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
@@ -55,6 +57,8 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
TrashCleanupModule,
PublicDomainModule,
EventLogCleanupModule,
MarketplaceModule,
AppVersionCheckModule,
],
providers: [
DataSeedWorkspaceCommand,
@@ -8,7 +8,7 @@ import { DataSource, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -6,7 +6,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
@@ -7,7 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -7,7 +7,7 @@ import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
@@ -13,7 +13,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -18,7 +18,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -10,7 +10,7 @@ import { v4 as uuidv4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -19,7 +19,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -9,7 +9,7 @@ import { DataSource, In, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -14,7 +14,7 @@ import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -7,7 +7,7 @@ import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
@@ -7,7 +7,7 @@ import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -0,0 +1,70 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddAppRegistrationSourceFields1772267875870
implements MigrationInterface
{
name = 'AddAppRegistrationSourceFields1772267875870';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "sourceType" text NOT NULL DEFAULT 'local'`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "sourcePackage" text`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "tarballFileId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "latestAvailableVersion" text`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "REL_36715821de396df9536fd4afc8" UNIQUE ("tarballFileId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_36715821de396df9536fd4afc81" FOREIGN KEY ("tarballFileId") REFERENCES "core"."file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "isFeatured" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "marketplaceDisplayData" jsonb`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration"
ADD CONSTRAINT "CHK_NPM_HAS_SOURCE_PACKAGE"
CHECK ("sourceType" <> 'npm' OR "sourcePackage" IS NOT NULL)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT IF EXISTS "CHK_NPM_HAS_SOURCE_PACKAGE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "marketplaceDisplayData"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "isFeatured"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_36715821de396df9536fd4afc81"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "REL_36715821de396df9536fd4afc8"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "latestAvailableVersion"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "tarballFileId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "sourcePackage"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "sourceType"`,
);
}
}
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@Module({
imports: [
ApplicationInstallModule,
ApplicationRegistrationModule,
ApplicationModule,
TokenModule,
FileStorageModule,
PermissionsModule,
],
providers: [
ApplicationDevelopmentResolver,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
})
export class ApplicationDevelopmentModule {}
@@ -1,4 +1,5 @@
import {
Logger,
UseFilters,
UseGuards,
UseInterceptors,
@@ -12,6 +13,9 @@ import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import {
@@ -24,8 +28,8 @@ import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/dtos/workspace-migration.dto';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -50,10 +54,14 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
export class ApplicationDevelopmentResolver {
private readonly logger = new Logger(ApplicationDevelopmentResolver.name);
constructor(
private readonly applicationTokenService: ApplicationTokenService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly fileStorageService: FileStorageService,
) {}
@@ -75,12 +83,33 @@ export class ApplicationDevelopmentResolver {
@Args() { manifest }: ApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId =
await this.resolveApplicationRegistrationId(
manifest.application.universalIdentifier,
{
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,
);
const workspaceMigration =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
applicationRegistrationId,
});
await this.syncRegistrationMetadata(
applicationRegistrationId,
manifest,
workspaceId,
);
return {
applicationUniversalIdentifier:
workspaceMigration.applicationUniversalIdentifier,
@@ -96,7 +125,7 @@ export class ApplicationDevelopmentResolver {
) {
return await this.applicationService.create({
...input,
sourceType: 'local',
sourceType: AppRegistrationSourceType.LOCAL,
workspaceId,
});
}
@@ -142,4 +171,75 @@ export class ApplicationDevelopmentResolver {
settings: { isTemporaryFile: false, toDelete: false },
});
}
private async resolveApplicationRegistrationId(
universalIdentifier: string,
metadata: {
name: string;
description?: string;
logoUrl?: string;
author?: string;
websiteUrl?: string;
termsUrl?: string;
},
workspaceId: string,
): Promise<string> {
const existingRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
if (existingRegistration) {
return existingRegistration.id;
}
const { applicationRegistration: newRegistration } =
await this.applicationRegistrationService.create(
{ ...metadata, universalIdentifier },
workspaceId,
null,
);
this.logger.log(
`Created app registration for ${metadata.name} (${universalIdentifier})`,
);
return newRegistration.id;
}
private async syncRegistrationMetadata(
applicationRegistrationId: string,
manifest: { application: ApplicationInput['manifest']['application'] },
workspaceId: string,
): Promise<void> {
const isOwner =
await this.applicationRegistrationService.isOwnedByWorkspace(
applicationRegistrationId,
workspaceId,
);
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,
);
}
}
}
@@ -7,6 +7,8 @@ import {
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -23,8 +25,14 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
case ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND:
throw new NotFoundError(exception);
case ApplicationExceptionCode.FORBIDDEN:
throw new ForbiddenError(exception);
case ApplicationExceptionCode.INVALID_INPUT:
case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH:
throw new UserInputError(exception);
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
case ApplicationExceptionCode.UPGRADE_FAILED:
throw new InternalServerError(exception);
default: {
assertUnreachable(exception.code);
}
@@ -0,0 +1,269 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { execFile } from 'child_process';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { promisify } from 'util';
import { type Manifest } from 'twenty-shared/application';
import { type PackageJson } from 'type-fest';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/constants/yarn-engine-dirname';
import { extractTarballSecurely } from 'src/engine/core-modules/application/utils/extract-tarball-securely.util';
import { readJsonFileOrThrow } from 'src/engine/core-modules/application/utils/read-json-file.util';
import { resolvePackageContentDir } from 'src/engine/core-modules/application/utils/tarball-utils';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
const execFilePromise = promisify(execFile);
const APP_RESOLVER_TMPDIR = join(tmpdir(), 'twenty-app-resolver');
const RESOLUTION_TIMEOUT_MS = 30_000;
export type ResolvedPackage = {
extractedDir: string;
manifest: Manifest;
packageJson: PackageJson;
};
@Injectable()
export class AppPackageResolverService implements OnModuleInit {
private readonly logger = new Logger(AppPackageResolverService.name);
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly fileStorageService: FileStorageService,
) {}
async onModuleInit(): Promise<void> {
try {
await fs.rm(APP_RESOLVER_TMPDIR, { recursive: true, force: true });
} catch {
// best-effort cleanup of stale temp files from previous runs
}
}
async resolvePackage(
appRegistration: ApplicationRegistrationEntity,
options?: { targetVersion?: string },
): Promise<ResolvedPackage | null> {
switch (appRegistration.sourceType) {
case AppRegistrationSourceType.NPM:
return this.resolveFromNpm(appRegistration, options?.targetVersion);
case AppRegistrationSourceType.TARBALL:
return this.resolveFromTarball(appRegistration);
case AppRegistrationSourceType.LOCAL:
return null;
}
}
async cleanupExtractedDir(extractedDir: string): Promise<void> {
try {
await fs.rm(extractedDir, { recursive: true, force: true });
} catch (error) {
this.logger.warn(`Failed to clean up ${extractedDir}: ${error}`);
}
}
private async resolveFromNpm(
appRegistration: ApplicationRegistrationEntity,
targetVersion?: string,
): Promise<ResolvedPackage> {
const workDir = join(APP_RESOLVER_TMPDIR, v4());
await fs.mkdir(workDir, { recursive: true });
try {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
const authToken = this.twentyConfigService.get('APP_REGISTRY_TOKEN');
if (!appRegistration.sourcePackage) {
throw new ApplicationException(
`App registration ${appRegistration.id} has sourceType=npm but no sourcePackage`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
const sourcePackage = appRegistration.sourcePackage;
const versionSpec = targetVersion ?? 'latest';
await this.writeNpmrc({
workDir,
packageName: sourcePackage,
registryUrl,
authToken,
});
await this.setupYarnEngine(workDir);
await this.writeMinimalPackageJson(workDir, sourcePackage, versionSpec);
await this.runYarnInstall(workDir);
const packageDir = join(workDir, 'node_modules', sourcePackage);
const manifest = await readJsonFileOrThrow<Manifest>(
packageDir,
'manifest.json',
);
const packageJson = await readJsonFileOrThrow<PackageJson>(
packageDir,
'package.json',
);
return { extractedDir: workDir, manifest, packageJson };
} catch (error) {
await this.cleanupExtractedDir(workDir);
throw new ApplicationException(
`Failed to resolve npm package ${appRegistration.sourcePackage}: ${error}`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
}
private async resolveFromTarball(
appRegistration: ApplicationRegistrationEntity,
): Promise<ResolvedPackage> {
const workDir = join(APP_RESOLVER_TMPDIR, v4());
await fs.mkdir(workDir, { recursive: true });
try {
const storagePath = join('app-tarball', appRegistration.id, 'app.tar.gz');
const tarballStream = await this.fileStorageService.readFileLegacy({
filePath: storagePath,
});
const tarballBuffer = await streamToBuffer(tarballStream);
const tarballPath = join(workDir, 'app.tar.gz');
await fs.writeFile(tarballPath, tarballBuffer);
await extractTarballSecurely(tarballPath, workDir);
await fs.rm(tarballPath);
const contentDir = await resolvePackageContentDir(workDir);
const manifest = await readJsonFileOrThrow<Manifest>(
contentDir,
'manifest.json',
);
const packageJson = await readJsonFileOrThrow<PackageJson>(
contentDir,
'package.json',
);
return { extractedDir: workDir, manifest, packageJson };
} catch (error) {
await this.cleanupExtractedDir(workDir);
if (error instanceof ApplicationException) {
throw error;
}
throw new ApplicationException(
`Failed to resolve tarball for app ${appRegistration.universalIdentifier}: ${error}`,
ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED,
);
}
}
// Note: .npmrc settings take precedence over publishConfig.registry in
// individual packages. This is correct for our use case since we want
// to control the registry at the resolver level.
private async writeNpmrc(config: {
workDir: string;
packageName: string;
registryUrl: string;
authToken?: string;
}): Promise<void> {
const lines: string[] = [];
const registryHost = new URL(config.registryUrl).host;
if (config.packageName.startsWith('@')) {
const scope = config.packageName.split('/')[0];
lines.push(`${scope}:registry=${config.registryUrl}`);
} else if (config.registryUrl !== 'https://registry.npmjs.org') {
lines.push(`registry=${config.registryUrl}`);
}
if (config.authToken) {
lines.push(`//${registryHost}/:_authToken=${config.authToken}`);
}
if (lines.length > 0) {
await fs.writeFile(
join(config.workDir, '.npmrc'),
lines.join('\n') + '\n',
);
}
}
private async setupYarnEngine(workDir: string): Promise<void> {
await fs.cp(YARN_ENGINE_DIRNAME, workDir, { recursive: true });
}
private async writeMinimalPackageJson(
workDir: string,
packageName: string,
versionSpec: string,
): Promise<void> {
const packageJson = {
name: 'twenty-app-resolver-workspace',
private: true,
dependencies: {
[packageName]: versionSpec,
},
};
await fs.writeFile(
join(workDir, 'package.json'),
JSON.stringify(packageJson, null, 2),
);
}
private async resolveLocalYarnPath(workDir: string): Promise<string> {
const yarnrcPath = join(workDir, '.yarnrc.yml');
const yarnrcContent = await fs.readFile(yarnrcPath, 'utf-8');
const match = yarnrcContent.match(/^yarnPath:\s*(.+)$/m);
if (!match) {
throw new ApplicationException(
'yarnPath not found in .yarnrc.yml',
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
return join(workDir, match[1].trim());
}
private async runYarnInstall(workDir: string): Promise<void> {
const localYarnPath = await this.resolveLocalYarnPath(workDir);
const { NODE_OPTIONS: _nodeOptions, ...cleanEnv } = process.env;
try {
await execFilePromise(
process.execPath,
[localYarnPath, 'install', '--no-immutable'],
{
cwd: workDir,
env: cleanEnv,
timeout: RESOLUTION_TIMEOUT_MS,
},
);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new ApplicationException(
`yarn install failed: ${errorMessage}`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
}
}
@@ -0,0 +1,129 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Repository } from 'typeorm';
import { z } from 'zod';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmPackageMetadataSchema = z.object({
version: z.string(),
});
@Injectable()
export class AppUpgradeService {
private readonly logger = new Logger(AppUpgradeService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly applicationInstallService: ApplicationInstallService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async checkForUpdates(
appRegistration: ApplicationRegistrationEntity,
): Promise<string | null> {
if (appRegistration.sourceType !== AppRegistrationSourceType.NPM) {
return null;
}
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
if (!appRegistration.sourcePackage) {
return null;
}
try {
const encodedPackage = encodeURIComponent(appRegistration.sourcePackage);
const { data } = await axios.get(
`${registryUrl}/${encodedPackage}/latest`,
{
headers: { 'User-Agent': 'Twenty-AppUpgrade' },
timeout: 10_000,
},
);
const parsed = npmPackageMetadataSchema.safeParse(data);
if (!parsed.success) {
this.logger.warn(
`Unexpected response shape from registry for ${appRegistration.sourcePackage}`,
);
return null;
}
await this.appRegistrationRepository.update(appRegistration.id, {
latestAvailableVersion: parsed.data.version,
});
return parsed.data.version;
} catch (error) {
this.logger.warn(
`Failed to check updates for ${appRegistration.sourcePackage}: ${error}`,
);
return null;
}
}
async checkAllForUpdates(): Promise<void> {
const npmRegistrations = await this.appRegistrationRepository.find({
where: { sourceType: AppRegistrationSourceType.NPM },
});
for (const registration of npmRegistrations) {
await this.checkForUpdates(registration);
}
}
async upgradeApplication(params: {
appRegistrationId: string;
targetVersion: string;
workspaceId: string;
}): Promise<boolean> {
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
where: { id: params.appRegistrationId },
});
if (
appRegistration.sourceType === AppRegistrationSourceType.LOCAL ||
appRegistration.sourceType === AppRegistrationSourceType.TARBALL
) {
throw new ApplicationException(
'Cannot upgrade an app installed from a tarball or local source',
ApplicationExceptionCode.UPGRADE_FAILED,
);
}
try {
return await this.applicationInstallService.installApplication({
appRegistrationId: params.appRegistrationId,
version: params.targetVersion,
workspaceId: params.workspaceId,
});
} catch (error) {
const appName =
appRegistration.sourcePackage ?? appRegistration.universalIdentifier;
// No rollback needed: manifest sync runs inside a transaction
throw new ApplicationException(
`Upgrade failed for ${appName}: ${error}`,
ApplicationExceptionCode.UPGRADE_FAILED,
);
}
}
}
@@ -1,20 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver';
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
import { MarketplaceResolver } from 'src/engine/core-modules/application/resolvers/marketplace.resolver';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver';
import { ApplicationQueryResolver } from 'src/engine/core-modules/application/application.resolver';
import { AppPackageResolverService } from 'src/engine/core-modules/application/application-install/app-package-resolver.service';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
import { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
@@ -24,9 +29,13 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
@Module({
imports: [
TypeOrmModule.forFeature([FileEntity]),
ApplicationRegistrationModule,
TypeOrmModule.forFeature([
FileEntity,
ApplicationRegistrationEntity,
ApplicationEntity,
]),
ApplicationModule,
CacheLockModule,
ApplicationVariableEntityModule,
TokenModule,
WorkspaceMigrationModule,
@@ -38,15 +47,23 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
FileStorageModule,
WorkspaceCacheModule,
WorkspaceMigrationRunnerModule,
TwentyConfigModule,
],
providers: [
ApplicationResolver,
ApplicationDevelopmentResolver,
MarketplaceResolver,
ApplicationQueryResolver,
ApplicationInstallResolver,
ApplicationManifestMigrationService,
ApplicationSyncService,
AppPackageResolverService,
ApplicationInstallService,
AppUpgradeService,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
exports: [ApplicationSyncService],
exports: [
ApplicationSyncService,
AppPackageResolverService,
ApplicationInstallService,
AppUpgradeService,
],
})
export class ApplicationSyncModule {}
export class ApplicationInstallModule {}
@@ -4,31 +4,26 @@ import {
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { Args, Mutation } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@@ -40,64 +35,14 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ApplicationResolver {
export class ApplicationInstallResolver {
constructor(
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly applicationTokenService: ApplicationTokenService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@Query(() => [ApplicationDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async findManyApplications(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.applicationService.findManyApplications(workspaceId);
}
@Query(() => ApplicationDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async findOneApplication(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string,
@Args('universalIdentifier', { type: () => UUIDScalarType, nullable: true })
universalIdentifier?: string,
) {
return await this.applicationService.findOneApplicationOrThrow({
id,
universalIdentifier,
workspaceId,
});
}
@Mutation(() => ApplicationTokenPairDTO)
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async renewApplicationToken(
@Args('applicationRefreshToken') applicationRefreshToken: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationTokenPairDTO> {
const applicationRefreshTokenPayload =
this.applicationTokenService.validateApplicationRefreshToken(
applicationRefreshToken,
);
if (applicationRefreshTokenPayload.workspaceId !== workspaceId) {
throw new ApplicationException(
'Refresh token workspace does not match authenticated workspace',
ApplicationExceptionCode.FORBIDDEN,
);
}
return this.applicationTokenService.renewApplicationTokens(
applicationRefreshTokenPayload,
);
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@@ -0,0 +1,215 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { promises as fs } from 'fs';
import { join, relative } from 'path';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
AppPackageResolverService,
type ResolvedPackage,
} from 'src/engine/core-modules/application/application-install/app-package-resolver.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
const FILE_FOLDER_MAPPING: Record<string, FileFolder> = {
'package.json': FileFolder.Dependencies,
'yarn.lock': FileFolder.Dependencies,
};
const FILE_FOLDER_PATTERN_MAPPING: Array<{
pattern: RegExp;
folder: FileFolder;
}> = [
{ pattern: /\.function\.mjs$/, folder: FileFolder.BuiltLogicFunction },
{
pattern: /\.front-component\.mjs$/,
folder: FileFolder.BuiltFrontComponent,
},
{ pattern: /^public\//, folder: FileFolder.PublicAsset },
];
@Injectable()
export class ApplicationInstallService {
private readonly logger = new Logger(ApplicationInstallService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly appPackageResolverService: AppPackageResolverService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly fileStorageService: FileStorageService,
private readonly cacheLockService: CacheLockService,
) {}
async installApplication(params: {
appRegistrationId: string;
version?: string;
workspaceId: string;
}): Promise<boolean> {
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
where: { id: params.appRegistrationId },
});
if (appRegistration.sourceType === AppRegistrationSourceType.LOCAL) {
this.logger.log(
`Skipping install for LOCAL app ${appRegistration.universalIdentifier} (files synced by CLI watcher in dev mode)`,
);
return true;
}
const lockKey = `app-install:${params.workspaceId}:${appRegistration.universalIdentifier}`;
return this.cacheLockService.withLock(
() =>
this.doInstallApplication(appRegistration, {
version: params.version,
workspaceId: params.workspaceId,
}),
lockKey,
{ ttl: 60_000, ms: 500, maxRetries: 120 },
);
}
private async doInstallApplication(
appRegistration: ApplicationRegistrationEntity,
params: { version?: string; workspaceId: string },
): Promise<boolean> {
let resolvedPackage: ResolvedPackage | null = null;
try {
resolvedPackage = await this.appPackageResolverService.resolvePackage(
appRegistration,
{ targetVersion: params.version },
);
if (!resolvedPackage) {
return true;
}
await this.writeFilesToStorage(
resolvedPackage.extractedDir,
appRegistration.universalIdentifier,
params.workspaceId,
);
await this.applicationSyncService.synchronizeFromManifest({
workspaceId: params.workspaceId,
manifest: resolvedPackage.manifest,
applicationRegistrationId: appRegistration.id,
});
await this.updateApplicationSourceType(
appRegistration.universalIdentifier,
params.workspaceId,
appRegistration.sourceType,
);
this.logger.log(
`Successfully installed app ${appRegistration.universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
);
return true;
} catch (error) {
this.logger.error(
`Failed to install app ${appRegistration.universalIdentifier}: ${error}`,
);
throw error;
} finally {
if (resolvedPackage) {
await this.appPackageResolverService.cleanupExtractedDir(
resolvedPackage.extractedDir,
);
}
}
}
private async writeFilesToStorage(
extractedDir: string,
applicationUniversalIdentifier: string,
workspaceId: string,
): Promise<void> {
const files = await this.collectFiles(extractedDir);
for (const filePath of files) {
const relativePath = relative(extractedDir, filePath);
const fileFolder = this.resolveFileFolder(relativePath);
const content = await fs.readFile(filePath);
await this.fileStorageService.writeFile({
sourceFile: content,
mimeType: undefined,
fileFolder,
applicationUniversalIdentifier,
workspaceId,
resourcePath: relativePath,
settings: { isTemporaryFile: false, toDelete: false },
});
}
}
private resolveFileFolder(relativePath: string): FileFolder {
const exact = FILE_FOLDER_MAPPING[relativePath];
if (isDefined(exact)) {
return exact;
}
for (const { pattern, folder } of FILE_FOLDER_PATTERN_MAPPING) {
if (pattern.test(relativePath)) {
return folder;
}
}
return FileFolder.Source;
}
private async collectFiles(dir: string): Promise<string[]> {
const result: string[] = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.name === 'node_modules' || entry.name === '.yarn') {
continue;
}
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
const subFiles = await this.collectFiles(fullPath);
result.push(...subFiles);
} else {
result.push(fullPath);
}
}
return result;
}
private async updateApplicationSourceType(
universalIdentifier: string,
workspaceId: string,
sourceType: AppRegistrationSourceType,
): Promise<void> {
await this.applicationRepository.update(
{ universalIdentifier, workspaceId },
{ sourceType },
);
}
}
@@ -8,7 +8,7 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/build-from-to-all-universal-flat-entity-maps.util';
import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
@@ -1,25 +1,23 @@
import { Injectable, Logger } from '@nestjs/common';
import { type Manifest } from 'twenty-shared/application';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { PackageJson } from 'type-fest';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/build-from-to-all-universal-flat-entity-maps.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/utils/get-application-sub-all-flat-entity-maps.util';
import { getDefaultApplicationPackageFields } from 'src/engine/core-modules/application/utils/get-default-application-package-fields.util';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
@@ -40,19 +38,21 @@ export class ApplicationSyncService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fileStorageService: FileStorageService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
) {}
public async synchronizeFromManifest({
workspaceId,
manifest,
}: ApplicationInput & {
applicationRegistrationId,
}: {
workspaceId: string;
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<WorkspaceMigration> {
const application = await this.syncApplication({
workspaceId,
manifest,
applicationRegistrationId,
});
const ownerFlatApplication: FlatApplication = application;
@@ -64,7 +64,7 @@ export class ApplicationSyncService {
ownerFlatApplication,
});
this.logger.log('Application sync from manifest completed');
this.logger.log('Application sync from manifest completed');
return workspaceMigration;
}
@@ -72,8 +72,11 @@ export class ApplicationSyncService {
private async syncApplication({
workspaceId,
manifest,
}: ApplicationInput & {
applicationRegistrationId,
}: {
workspaceId: string;
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<ApplicationEntity> {
const name = manifest.application.displayName;
const packageJson = JSON.parse(
@@ -103,7 +106,7 @@ export class ApplicationSyncService {
name,
description: manifest.application.description,
version: packageJson.version,
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
sourcePath: 'cli-sync',
defaultRoleId: null,
workspaceId,
packageJsonChecksum: defaultPackageFields.packageJsonChecksum,
@@ -122,47 +125,8 @@ export class ApplicationSyncService {
},
);
const applicationRegistrationMetadata = {
name,
description: manifest.application.description,
logoUrl: manifest.application.logoUrl,
author: manifest.application.author,
websiteUrl: manifest.application.websiteUrl,
termsUrl: manifest.application.termsUrl,
};
const applicationRegistrationId =
await this.resolveApplicationRegistrationId(
application.applicationRegistrationId,
manifest.application.universalIdentifier,
applicationRegistrationMetadata,
workspaceId,
);
// Only update registration metadata if this workspace owns it.
// Other workspaces that install the same app attach to the existing
// registration but must not be able to modify its metadata.
if (
await this.applicationRegistrationService.isOwnedByWorkspace(
applicationRegistrationId,
workspaceId,
)
) {
await this.applicationRegistrationService.update(
{
id: applicationRegistrationId,
update: applicationRegistrationMetadata,
},
workspaceId,
);
}
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
manifest.application.serverVariables,
);
}
const resolvedRegistrationId =
applicationRegistrationId ?? application.applicationRegistrationId;
return await this.applicationService.update(application.id, {
name,
@@ -170,7 +134,8 @@ export class ApplicationSyncService {
version: packageJson.version,
packageJsonChecksum: manifest.application.packageJsonChecksum,
yarnLockChecksum: manifest.application.yarnLockChecksum,
applicationRegistrationId,
applicationRegistrationId: resolvedRegistrationId,
workspaceId,
});
}
@@ -250,44 +215,4 @@ export class ApplicationSyncService {
return validateAndBuildResult.workspaceMigration;
}
private async resolveApplicationRegistrationId(
existingId: string | null,
universalIdentifier: string,
metadata: {
name: string;
description?: string;
logoUrl?: string;
author?: string;
websiteUrl?: string;
termsUrl?: string;
},
workspaceId: string,
): Promise<string> {
if (existingId) {
return existingId;
}
const existingRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
if (existingRegistration) {
return existingRegistration.id;
}
const { applicationRegistration: newRegistration } =
await this.applicationRegistrationService.create(
{ ...metadata, universalIdentifier },
workspaceId,
null,
);
this.logger.log(
`Created app registration for ${metadata.name} (${universalIdentifier})`,
);
return newRegistration.id;
}
}
@@ -0,0 +1,179 @@
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;
};
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,
sourcePackage: '@twentyhq/app-data-enrichment',
isFeatured: true,
name: 'Data Enrichment',
description: 'Enrich your data easily. Choose your provider.',
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: [],
},
},
];
@@ -0,0 +1,35 @@
import { Command, CommandRunner } from 'nest-commander';
import { MARKETPLACE_CATALOG_SYNC_CRON_PATTERN } from 'src/engine/core-modules/application/application-marketplace/crons/constants/marketplace-catalog-sync-cron-pattern.constant';
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';
@Command({
name: 'cron:marketplace-catalog-sync',
description:
'Starts a cron job to sync the marketplace catalog into ApplicationRegistration',
})
export class MarketplaceCatalogSyncCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.add(MarketplaceCatalogSyncCronJob.name, {});
await this.messageQueueService.addCron<undefined>({
jobName: MarketplaceCatalogSyncCronJob.name,
data: undefined,
options: {
repeat: {
pattern: MARKETPLACE_CATALOG_SYNC_CRON_PATTERN,
},
},
});
}
}
@@ -0,0 +1 @@
export const MARKETPLACE_CATALOG_SYNC_CRON_PATTERN = '0 * * * *';
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { MARKETPLACE_CATALOG_SYNC_CRON_PATTERN } from 'src/engine/core-modules/application/application-marketplace/crons/constants/marketplace-catalog-sync-cron-pattern.constant';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-catalog-sync.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Injectable()
@Processor(MessageQueue.cronQueue)
export class MarketplaceCatalogSyncCronJob {
private readonly logger = new Logger(MarketplaceCatalogSyncCronJob.name);
constructor(
private readonly marketplaceCatalogSyncService: MarketplaceCatalogSyncService,
) {}
@Process(MarketplaceCatalogSyncCronJob.name)
@SentryCronMonitor(
MarketplaceCatalogSyncCronJob.name,
MARKETPLACE_CATALOG_SYNC_CRON_PATTERN,
)
async handle(): Promise<void> {
this.logger.log('Starting marketplace catalog sync...');
try {
await this.marketplaceCatalogSyncService.syncCatalog();
this.logger.log('Marketplace catalog sync completed successfully');
} catch (error) {
this.logger.error(
`Marketplace catalog sync failed: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
}
@@ -7,7 +7,9 @@ import {
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
@ObjectType('MarketplaceAppField')
export class MarketplaceAppFieldDTO {
@@ -274,6 +276,13 @@ export class MarketplaceAppDTO {
frontComponents: MarketplaceAppFrontComponentDTO[];
@IsOptional()
@ValidateNested()
@Type(() => MarketplaceAppDefaultRoleDTO)
@Field(() => MarketplaceAppDefaultRoleDTO, { nullable: true })
defaultRole?: MarketplaceAppDefaultRoleDTO;
@IsOptional()
@IsString()
@Field({ nullable: true })
sourcePackage?: string;
}
@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-catalog-sync.service';
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-query.service';
import { MarketplaceResolver } from 'src/engine/core-modules/application/application-marketplace/resolvers/marketplace.resolver';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace.service';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
ApplicationInstallModule,
PermissionsModule,
TwentyConfigModule,
],
providers: [
MarketplaceService,
MarketplaceCatalogSyncService,
MarketplaceQueryService,
MarketplaceCatalogSyncCronJob,
MarketplaceCatalogSyncCronCommand,
MarketplaceResolver,
],
exports: [
MarketplaceCatalogSyncService,
MarketplaceQueryService,
MarketplaceCatalogSyncCronCommand,
],
})
export class MarketplaceModule {}
@@ -0,0 +1,88 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
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 { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-query.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
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';
@MetadataResolver()
@UseFilters(ApplicationRegistrationExceptionFilter)
@UseGuards(UserAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
export class MarketplaceResolver {
constructor(
private readonly marketplaceQueryService: MarketplaceQueryService,
private readonly applicationInstallService: ApplicationInstallService,
private readonly appUpgradeService: AppUpgradeService,
) {}
@Query(() => [MarketplaceAppDTO])
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
return this.marketplaceQueryService.findManyMarketplaceApps();
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
async installMarketplaceApp(
@Args('universalIdentifier') universalIdentifier: string,
@Args('version', { type: () => String, nullable: true })
version: string | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const registration =
await this.marketplaceQueryService.findRegistrationByUniversalIdentifier(
universalIdentifier,
);
return this.applicationInstallService.installApplication({
appRegistrationId: registration.id,
version,
workspaceId: workspace.id,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
async installNpmApp(
@Args('packageName') packageName: string,
@Args('version', { type: () => String, nullable: true })
version: string | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const registration =
await this.marketplaceQueryService.findOrCreateNpmRegistration({
packageName,
ownerWorkspaceId: workspace.id,
});
return this.applicationInstallService.installApplication({
appRegistrationId: registration.id,
version,
workspaceId: workspace.id,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
async upgradeApplication(
@Args('appRegistrationId') appRegistrationId: string,
@Args('targetVersion') targetVersion: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
return this.appUpgradeService.upgradeApplication({
appRegistrationId,
targetVersion,
workspaceId: workspace.id,
});
}
}
@@ -0,0 +1,171 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-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/services/marketplace.service';
import { getAdminWorkspaceId } from 'src/engine/core-modules/application/application-marketplace/utils/get-admin-workspace-id.util';
@Injectable()
export class MarketplaceCatalogSyncService {
private readonly logger = new Logger(MarketplaceCatalogSyncService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly marketplaceService: MarketplaceService,
) {}
async syncCatalog(): Promise<void> {
const dataSource = this.appRegistrationRepository.manager.connection;
const adminWorkspaceId = await getAdminWorkspaceId(dataSource);
if (!isDefined(adminWorkspaceId)) {
this.logger.warn(
'No admin workspace found. Skipping marketplace catalog sync.',
);
return;
}
await this.syncCuratedApps(adminWorkspaceId);
await this.syncNpmApps(adminWorkspaceId);
this.logger.log('Marketplace catalog sync completed');
}
private async syncCuratedApps(ownerWorkspaceId: string): Promise<void> {
for (const entry of MARKETPLACE_CATALOG_INDEX) {
try {
await this.upsertRegistration({
universalIdentifier: entry.universalIdentifier,
name: entry.name,
description:
entry.richDisplayData.aboutDescription ?? entry.description,
author: entry.author,
sourceType: AppRegistrationSourceType.NPM,
sourcePackage: entry.sourcePackage,
logoUrl: entry.logoUrl ?? null,
websiteUrl: entry.websiteUrl ?? null,
termsUrl: entry.termsUrl ?? null,
latestAvailableVersion: entry.richDisplayData.version ?? null,
isFeatured: entry.isFeatured,
marketplaceDisplayData: entry.richDisplayData,
ownerWorkspaceId,
});
} catch (error) {
this.logger.error(
`Failed to sync curated app "${entry.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
private async syncNpmApps(ownerWorkspaceId: string): Promise<void> {
const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry();
const curatedIdentifiers = new Set(
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
);
for (const app of npmApps) {
if (curatedIdentifiers.has(app.id)) {
continue;
}
try {
await this.upsertRegistration({
universalIdentifier: app.id,
name: app.name,
description: app.description,
author: app.author,
sourceType: AppRegistrationSourceType.NPM,
sourcePackage: app.sourcePackage ?? app.name,
logoUrl: null,
websiteUrl: app.websiteUrl ?? null,
termsUrl: null,
latestAvailableVersion: app.version ?? null,
isFeatured: false,
marketplaceDisplayData: null,
ownerWorkspaceId,
});
} catch (error) {
this.logger.error(
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
// Lookup by universalIdentifier only (matches the unique constraint).
// ownerWorkspaceId is only set on insert.
private async upsertRegistration(
params: Pick<
ApplicationRegistrationEntity,
| 'universalIdentifier'
| 'name'
| 'description'
| 'author'
| 'sourceType'
| 'sourcePackage'
| 'logoUrl'
| 'websiteUrl'
| 'termsUrl'
| 'latestAvailableVersion'
| 'isFeatured'
| 'marketplaceDisplayData'
| 'ownerWorkspaceId'
>,
): Promise<void> {
const existing = await this.appRegistrationRepository.findOne({
where: {
universalIdentifier: params.universalIdentifier,
},
});
if (isDefined(existing)) {
await this.appRegistrationRepository.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,
isFeatured: params.isFeatured,
marketplaceDisplayData: params.marketplaceDisplayData,
});
return;
}
const registration = this.appRegistrationRepository.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,
isFeatured: params.isFeatured,
marketplaceDisplayData: params.marketplaceDisplayData,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
await this.appRegistrationRepository.save(registration);
}
}
@@ -0,0 +1,140 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
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 { 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;
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {}
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
if (this.cachedApps !== null && Date.now() < this.cacheExpiresAt) {
return this.cachedApps;
}
const registrations = await this.appRegistrationRepository.find({
where: { sourceType: AppRegistrationSourceType.NPM },
});
if (registrations.length === 0) {
this.logger.log(
'No marketplace registrations found, enqueuing one-time sync job',
);
await this.messageQueueService.add(
MarketplaceCatalogSyncCronJob.name,
{},
);
return [];
}
this.cachedApps = registrations.map((registration) =>
this.toMarketplaceAppDTO(registration),
);
this.cacheExpiresAt = Date.now() + MARKETPLACE_CACHE_TTL_MS;
return this.cachedApps;
}
async findRegistrationByUniversalIdentifier(
universalIdentifier: string,
): Promise<ApplicationRegistrationEntity> {
const registration = await this.appRegistrationRepository.findOne({
where: { universalIdentifier },
});
if (!isDefined(registration)) {
throw new ApplicationRegistrationException(
`No application registration found for identifier "${universalIdentifier}"`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
async findOrCreateNpmRegistration(params: {
packageName: string;
ownerWorkspaceId: string;
}): Promise<ApplicationRegistrationEntity> {
const existing = await this.appRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});
if (isDefined(existing)) {
return existing;
}
this.logger.log(
`Creating new registration for npm package "${params.packageName}"`,
);
const registration = this.appRegistrationRepository.create({
universalIdentifier: v4(),
name: params.packageName,
sourceType: AppRegistrationSourceType.NPM,
sourcePackage: params.packageName,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
return this.appRegistrationRepository.save(registration);
}
toMarketplaceAppDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDTO {
const displayData = registration.marketplaceDisplayData;
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 ?? [],
sourcePackage: registration.sourcePackage ?? undefined,
defaultRole: displayData?.defaultRole,
};
}
}
@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmSearchResultSchema = z.object({
objects: z.array(
z.object({
package: z.object({
name: z.string(),
version: z.string(),
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(),
}),
}),
),
});
@Injectable()
export class MarketplaceService {
private readonly logger = new Logger(MarketplaceService.name);
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async fetchAppsFromNpmRegistry(): Promise<MarketplaceAppDTO[]> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
try {
const { data } = await axios.get(
`${registryUrl}/-/v1/search?text=keywords:twenty-app&size=250`,
{
headers: { 'User-Agent': 'Twenty-Marketplace' },
timeout: 10_000,
},
);
const parsed = npmSearchResultSchema.safeParse(data);
if (!parsed.success) {
this.logger.warn(
`Unexpected npm 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:'),
);
const universalIdentifier = isDefined(twentyKeyword)
? twentyKeyword.replace('twenty-uid:', '')
: name;
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,
};
});
} catch (error) {
this.logger.warn(
`Failed to fetch apps from npm registry: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
}
@@ -0,0 +1,75 @@
// 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,36 @@
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type DataSource } from 'typeorm';
// Every ApplicationRegistration must be owned by a workspace (ownerWorkspaceId
// represents ownership / write-access, not visibility scoping — marketplace
// registrations are readable by all workspaces). When the catalog sync creates
// registrations for marketplace apps that no developer has explicitly claimed,
// we assign them to the "admin" workspace: the oldest active workspace whose
// owner has admin privileges.
//
// TODO: This heuristic is fragile — on fresh instances with no admin users the
// catalog sync is silently skipped, and if the admin workspace is later deleted
// all marketplace registrations become orphaned. Consider introducing a
// dedicated "platform" workspace or making ownerWorkspaceId nullable instead.
export const getAdminWorkspaceId = async (
dataSource: DataSource,
): Promise<string | null> => {
const result = await dataSource.query<Array<{ workspaceId: string }>>(
`SELECT uw."workspaceId"
FROM core."userWorkspace" uw
JOIN core."user" u ON u.id = uw."userId" AND u."deletedAt" IS NULL
JOIN core."workspace" w ON w.id = uw."workspaceId" AND w."deletedAt" IS NULL
WHERE (u."canAccessFullAdminPanel" = true OR u."canImpersonate" = true)
AND w."activationStatus" = $1
AND uw."deletedAt" IS NULL
ORDER BY w."createdAt" ASC
LIMIT 1`,
[WorkspaceActivationStatus.ACTIVE],
);
if (result.length === 0) {
return null;
}
return result[0].workspaceId;
};
@@ -2,7 +2,7 @@ import {
ALL_OAUTH_SCOPES,
OAUTH_SCOPE_DESCRIPTIONS,
OAUTH_SCOPES,
} from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
} from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
describe('OAuth Scopes', () => {
it('should have all scopes defined', () => {
@@ -0,0 +1,30 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import {
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(ApplicationRegistrationException)
export class ApplicationRegistrationExceptionFilter implements ExceptionFilter {
catch(exception: ApplicationRegistrationException) {
switch (exception.code) {
case ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND:
throw new NotFoundError(exception);
case ApplicationRegistrationExceptionCode.INVALID_INPUT:
case ApplicationRegistrationExceptionCode.INVALID_SCOPE:
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH:
case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
throw new UserInputError(exception);
default:
throw new InternalServerError(exception);
}
}
}
@@ -15,7 +15,7 @@ import {
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
@Entity({ name: 'applicationRegistrationVariable', schema: 'core' })
@ObjectType('ApplicationRegistrationVariable')
@@ -5,14 +5,14 @@ import { type ServerVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, type Repository } from 'typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application-registration/application-registration.exception';
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration-variable.input';
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration-variable.input';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
@@ -176,7 +176,7 @@ export class ApplicationRegistrationVariableService {
workspaceId: string,
): Promise<void> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id: registrationId, workspaceId },
where: { id: registrationId, ownerWorkspaceId: workspaceId },
});
if (!registration) {
@@ -2,6 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Check,
Column,
CreateDateColumn,
DeleteDateColumn,
@@ -10,13 +11,17 @@ import {
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -39,7 +44,11 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
},
)
@Index('IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
@Index('IDX_APPLICATION_REGISTRATION_WORKSPACE_ID', ['workspaceId'])
@Index('IDX_APPLICATION_REGISTRATION_WORKSPACE_ID', ['ownerWorkspaceId'])
@Check(
'CHK_NPM_HAS_SOURCE_PACKAGE',
`"sourceType" <> 'npm' OR "sourcePackage" IS NOT NULL`,
)
export class ApplicationRegistrationEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
@@ -87,13 +96,38 @@ export class ApplicationRegistrationEntity {
@JoinColumn({ name: 'createdByUserId' })
createdByUser: Relation<UserEntity> | null;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
// Represents ownership (who can edit), not visibility scoping.
// Marketplace registrations are readable by all workspaces but owned by the
// admin workspace when no developer has explicitly claimed them.
@Column({ name: 'workspaceId', nullable: false, type: 'uuid' })
ownerWorkspaceId: string;
@ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
@Field(() => AppRegistrationSourceType)
@Column({
type: 'text',
default: AppRegistrationSourceType.LOCAL,
})
sourceType: AppRegistrationSourceType;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
sourcePackage: string | null;
@Column({ nullable: true, type: 'uuid' })
tarballFileId: string | null;
@OneToOne(() => FileEntity, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'tarballFileId' })
tarballFile: Relation<FileEntity> | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
latestAvailableVersion: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
websiteUrl: string | null;
@@ -102,6 +136,13 @@ export class ApplicationRegistrationEntity {
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@Field(() => Boolean)
@Column({ name: 'isFeatured', type: 'boolean', default: false })
isFeatured: boolean;
@Column({ type: 'jsonb', nullable: true })
marketplaceDisplayData: MarketplaceDisplayData | null;
@OneToMany(
() => ApplicationRegistrationVariableEntity,
(variable) => variable.applicationRegistration,
@@ -9,6 +9,8 @@ export enum ApplicationRegistrationExceptionCode {
UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED = 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
INVALID_SCOPE = 'INVALID_SCOPE',
INVALID_REDIRECT_URI = 'INVALID_REDIRECT_URI',
INVALID_INPUT = 'INVALID_INPUT',
SOURCE_CHANNEL_MISMATCH = 'SOURCE_CHANNEL_MISMATCH',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
}
@@ -24,6 +26,10 @@ const getExceptionUserFriendlyMessage = (
return msg`One or more requested scopes are invalid.`;
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
return msg`One or more redirect URIs are invalid.`;
case ApplicationRegistrationExceptionCode.INVALID_INPUT:
return msg`Invalid input for application registration.`;
case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH:
return msg`The app source channel does not match the expected type.`;
case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND:
return msg`Application registration variable not found.`;
default:
@@ -1,22 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application-registration/application-registration.resolver';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { OAuthDiscoveryController } from 'src/engine/core-modules/application-registration/controllers/oauth-discovery.controller';
import { OAuthTokenController } from 'src/engine/core-modules/application-registration/controllers/oauth-token.controller';
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { OAuthDiscoveryController } from 'src/engine/core-modules/application/application-registration/controllers/oauth-discovery.controller';
import { OAuthTokenController } from 'src/engine/core-modules/application/application-registration/controllers/oauth-token.controller';
import { OAuthService } from 'src/engine/core-modules/application/application-registration/oauth.service';
import { AppTarballUploadService } from 'src/engine/core-modules/application/application-registration/services/app-tarball-upload.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
@@ -32,12 +36,16 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
ThrottlerModule,
TokenModule,
ApplicationModule,
ApplicationInstallModule,
FileStorageModule,
WorkspaceCacheStorageModule,
],
controllers: [OAuthTokenController, OAuthDiscoveryController],
providers: [
ApplicationRegistrationService,
ApplicationRegistrationVariableService,
ApplicationRegistrationResolver,
AppTarballUploadService,
OAuthService,
],
exports: [
@@ -1,20 +1,32 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.dto';
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/public-application-registration.dto';
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
import { RotateClientSecretDTO } from 'src/engine/core-modules/application-registration/dtos/rotate-client-secret.dto';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import {
AppTarballUploadService,
MAX_TARBALL_UPLOAD_SIZE_BYTES,
} from 'src/engine/core-modules/application/application-registration/services/app-tarball-upload.service';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.dto';
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration-variable.input';
import { RotateClientSecretDTO } from 'src/engine/core-modules/application/application-registration/dtos/rotate-client-secret.dto';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration-variable.input';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -27,10 +39,12 @@ import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseFilters(
ApplicationRegistrationExceptionFilter,
AuthGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
@@ -38,6 +52,7 @@ export class ApplicationRegistrationResolver {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly appTarballUploadService: AppTarballUploadService,
) {}
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -211,4 +226,33 @@ export class ApplicationRegistrationResolver {
workspaceId,
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS),
)
@Mutation(() => ApplicationRegistrationEntity)
async uploadAppTarball(
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream }: FileUpload,
@Args('universalIdentifier', { type: () => String, nullable: true })
universalIdentifier: string | undefined,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationRegistrationEntity> {
const stream = createReadStream();
const tarballBuffer = await streamToBuffer(stream);
if (tarballBuffer.length > MAX_TARBALL_UPLOAD_SIZE_BYTES) {
throw new ApplicationRegistrationException(
`Tarball exceeds maximum size of ${MAX_TARBALL_UPLOAD_SIZE_BYTES} bytes`,
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
return this.appTarballUploadService.uploadTarball({
tarballBuffer,
universalIdentifier,
ownerWorkspaceId: workspaceId,
});
}
}
@@ -8,16 +8,16 @@ import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application-registration/application-registration.exception';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/public-application-registration.dto';
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
@@ -33,20 +33,20 @@ export class ApplicationRegistrationService {
) {}
async findMany(
workspaceId: string,
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { workspaceId },
where: { ownerWorkspaceId },
order: { createdAt: 'DESC' },
});
}
async findOneById(
id: string,
workspaceId: string,
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationEntity> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id, workspaceId },
where: { id, ownerWorkspaceId },
});
if (!registration) {
@@ -93,10 +93,10 @@ export class ApplicationRegistrationService {
async isOwnedByWorkspace(id: string, workspaceId: string): Promise<boolean> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
select: ['id', 'workspaceId'],
select: ['id', 'ownerWorkspaceId'],
});
return registration?.workspaceId === workspaceId;
return registration?.ownerWorkspaceId === workspaceId;
}
// Global lookup — used by app sync to find existing registrations
@@ -110,7 +110,7 @@ export class ApplicationRegistrationService {
async create(
input: CreateApplicationRegistrationInput,
workspaceId: string,
ownerWorkspaceId: string,
createdByUserId: string | null,
): Promise<{
applicationRegistration: ApplicationRegistrationEntity;
@@ -152,7 +152,7 @@ export class ApplicationRegistrationService {
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
oAuthScopes: input.oAuthScopes ?? [],
createdByUserId,
workspaceId,
ownerWorkspaceId,
websiteUrl: input.websiteUrl ?? null,
termsUrl: input.termsUrl ?? null,
});
@@ -166,11 +166,11 @@ export class ApplicationRegistrationService {
async update(
input: UpdateApplicationRegistrationInput,
workspaceId: string,
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationEntity> {
const { id, update } = input;
await this.findOneById(id, workspaceId);
await this.findOneById(id, ownerWorkspaceId);
if (isDefined(update.oAuthRedirectUris)) {
this.validateRedirectUris(update.oAuthRedirectUris);
@@ -198,18 +198,21 @@ export class ApplicationRegistrationService {
await this.applicationRegistrationRepository.update(id, updateData);
}
return this.findOneById(id, workspaceId);
return this.findOneById(id, ownerWorkspaceId);
}
async delete(id: string, workspaceId: string): Promise<boolean> {
await this.findOneById(id, workspaceId);
async delete(id: string, ownerWorkspaceId: string): Promise<boolean> {
await this.findOneById(id, ownerWorkspaceId);
await this.applicationRegistrationRepository.softDelete(id);
return true;
}
async rotateClientSecret(id: string, workspaceId: string): Promise<string> {
await this.findOneById(id, workspaceId);
async rotateClientSecret(
id: string,
ownerWorkspaceId: string,
): Promise<string> {
await this.findOneById(id, ownerWorkspaceId);
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
@@ -234,9 +237,9 @@ export class ApplicationRegistrationService {
async getStats(
applicationRegistrationId: string,
workspaceId: string,
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationStatsDTO> {
await this.findOneById(applicationRegistrationId, workspaceId);
await this.findOneById(applicationRegistrationId, ownerWorkspaceId);
const versionDistribution: { version: string; count: number }[] =
await this.applicationRepository
@@ -1,6 +1,6 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@@ -13,12 +13,12 @@ import {
import { type Request, type Response } from 'express';
import { OAuthIntrospectInput } from 'src/engine/core-modules/application-registration/dtos/oauth-introspect.input';
import { OAuthRevokeInput } from 'src/engine/core-modules/application-registration/dtos/oauth-revoke.input';
import { OAuthTokenInput } from 'src/engine/core-modules/application-registration/dtos/oauth-token.input';
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
import { OAuthIntrospectInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-introspect.input';
import { OAuthRevokeInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-revoke.input';
import { OAuthTokenInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-token.input';
import { OAuthService } from 'src/engine/core-modules/application/application-registration/oauth.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-token-response.type';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { ThrottlerException } from 'src/engine/core-modules/throttler/throttler.exception';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
@@ -1,6 +1,6 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
@ObjectType('CreateApplicationRegistration')
export class CreateApplicationRegistrationDTO {
@@ -0,0 +1,11 @@
import { registerEnumType } from '@nestjs/graphql';
export enum AppRegistrationSourceType {
NPM = 'npm',
TARBALL = 'tarball',
LOCAL = 'local',
}
registerEnumType(AppRegistrationSourceType, {
name: 'AppRegistrationSourceType',
});

Some files were not shown because too many files have changed in this diff Show More