feat(server): refresh application registration on install (#22527)

Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — unifies registration ingestion across
sources.

## Problem

The dev sync, catalog sync and tarball upload flows all refresh the
`applicationRegistration` row (manifest + display columns) at ingestion
time, but the install/upgrade flow never did. Installing or upgrading an
app relied on catalog sync having run beforehand, so a registration
could serve stale display data (name, logo, description, screenshots…)
after an install that shipped a newer manifest.

## Changes

- `doInstallApplication` now refreshes the global registration from the
resolved manifest after all install steps succeed (post-install hook
included, so a hook failure that triggers uninstall can't leave the
registration refreshed for a failed install).
- Downgrade guard: the refresh is skipped when the installed version is
provably older than `latestAvailableVersion` (per-workspace installs of
an older version never downgrade the global registration). Extracted as
a pure util `shouldRefreshApplicationRegistrationOnInstall` with unit
tests:
  - `latestAvailableVersion` null or invalid semver → refresh
- installed ≥ latest → refresh, and `latestAvailableVersion` is bumped
to the installed version
- installed < latest, or installed not valid semver while latest is →
skip
- Asset URLs mirror the existing per-source ingestion behavior: NPM
registrations get manifest `logoUrl`/`screenshots` resolved to registry
CDN URLs (same as catalog sync); tarball and other sources persist the
manifest as-is (same as tarball upload).
- `updateFromManifest` gains an optional `latestAvailableVersion` param
(same conditional-spread style as `sourceType`).
- `ApplicationRegistrationModule` added to `ApplicationInstallModule`
imports (no cycle: nothing in the registration module's import graph
imports the install module).

The dev sync flow (`syncRegistrationMetadata`) already goes through
`updateFromManifest` and writes the display columns — verified, no
change needed.

## Verification

- New unit spec: 6 cases on the guard util
- `npx jest "application-registration|application-install|marketplace"`
→ 3 suites, 21 tests passed
- `npx nx typecheck twenty-server` → success
- `npx nx lint:diff-with-main twenty-server` → clean

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22527?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
martmull
2026-07-04 22:45:12 +02:00
committed by GitHub
parent 99f99adf8f
commit 3db09e4423
11 changed files with 505 additions and 311 deletions
@@ -3106,7 +3106,6 @@ type Query {
getViewGroup(id: String!): ViewGroup
findManyApplications: [Application!]!
findOneApplication(id: UUID, universalIdentifier: UUID): Application!
getRoles: [Role!]!
findApplicationRegistrationByClientId(clientId: String!): PublicApplicationRegistration
findApplicationRegistrationByUniversalIdentifier(universalIdentifier: String!): ApplicationRegistration
findManyApplicationRegistrations: [ApplicationRegistration!]!
@@ -3114,6 +3113,7 @@ type Query {
findApplicationRegistrationStats(id: String!): ApplicationRegistrationStats!
findApplicationRegistrationVariables(applicationRegistrationId: String!): [ApplicationRegistrationVariableDTO!]!
applicationRegistrationTarballUrl(id: String!): String
getRoles: [Role!]!
previewMessageCampaignAudience(input: PreviewMessageCampaignAudienceInput!): CampaignAudiencePreviewDTO!
unsubscribeTopics: [UnsubscribeTopic!]!
unsubscribePagePreviewUrl: String!
@@ -3363,6 +3363,15 @@ type Mutation {
destroyViewGroup(input: DestroyViewGroupInput!): ViewGroup!
runWorkspaceMigration(workspaceMigration: WorkspaceMigrationInput!): Boolean!
uninstallApplication(universalIdentifier: String!): Boolean!
createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration!
updateApplicationRegistration(input: UpdateApplicationRegistrationInput!): ApplicationRegistration!
deleteApplicationRegistration(id: String!): Boolean!
rotateApplicationRegistrationClientSecret(id: String!): RotateClientSecret!
createApplicationRegistrationVariable(input: CreateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
deleteApplicationRegistrationVariable(id: String!): Boolean!
uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration!
transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration!
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
createOneRole(createRoleInput: CreateRoleInput!): Role!
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
@@ -3373,15 +3382,6 @@ type Mutation {
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
removeRoleFromAgent(agentId: UUID!): Boolean!
createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration!
updateApplicationRegistration(input: UpdateApplicationRegistrationInput!): ApplicationRegistration!
deleteApplicationRegistration(id: String!): Boolean!
rotateApplicationRegistrationClientSecret(id: String!): RotateClientSecret!
createApplicationRegistrationVariable(input: CreateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
deleteApplicationRegistrationVariable(id: String!): Boolean!
uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration!
transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration!
sendEmailViaEmailingDomain(input: SendEmailViaDomainInput!): SendEmailViaDomainOutput!
sendMessageCampaign(input: SendMessageCampaignInput!): SendMessageCampaignOutputDTO!
createUnsubscribeTopic(input: CreateUnsubscribeTopicInput!): UnsubscribeTopic!
@@ -4279,6 +4279,45 @@ enum WorkspaceMigrationActionType {
update
}
input CreateApplicationRegistrationInput {
name: String!
universalIdentifier: String
oAuthRedirectUris: [String!]
oAuthScopes: [String!]
}
input UpdateApplicationRegistrationInput {
id: String!
update: UpdateApplicationRegistrationPayload!
}
input UpdateApplicationRegistrationPayload {
name: String
oAuthRedirectUris: [String!]
oAuthScopes: [String!]
isListed: Boolean
isPreInstalled: Boolean
}
input CreateApplicationRegistrationVariableInput {
applicationRegistrationId: String!
key: String!
value: String!
description: String
isSecret: Boolean
}
input UpdateApplicationRegistrationVariableInput {
id: String!
update: UpdateApplicationRegistrationVariablePayload!
}
input UpdateApplicationRegistrationVariablePayload {
value: String
resetValue: Boolean
description: String
}
input CreateRoleInput {
id: String
label: String!
@@ -4374,45 +4413,6 @@ input RowLevelPermissionPredicateGroupInput {
positionInRowLevelPermissionPredicateGroup: Float
}
input CreateApplicationRegistrationInput {
name: String!
universalIdentifier: String
oAuthRedirectUris: [String!]
oAuthScopes: [String!]
}
input UpdateApplicationRegistrationInput {
id: String!
update: UpdateApplicationRegistrationPayload!
}
input UpdateApplicationRegistrationPayload {
name: String
oAuthRedirectUris: [String!]
oAuthScopes: [String!]
isListed: Boolean
isPreInstalled: Boolean
}
input CreateApplicationRegistrationVariableInput {
applicationRegistrationId: String!
key: String!
value: String!
description: String
isSecret: Boolean
}
input UpdateApplicationRegistrationVariableInput {
id: String!
update: UpdateApplicationRegistrationVariablePayload!
}
input UpdateApplicationRegistrationVariablePayload {
value: String
resetValue: Boolean
description: String
}
input SendEmailViaDomainInput {
emailingDomainId: String!
to: [String!]!
@@ -2714,7 +2714,6 @@ export interface Query {
getViewGroup?: ViewGroup
findManyApplications: Application[]
findOneApplication: Application
getRoles: Role[]
findApplicationRegistrationByClientId?: PublicApplicationRegistration
findApplicationRegistrationByUniversalIdentifier?: ApplicationRegistration
findManyApplicationRegistrations: ApplicationRegistration[]
@@ -2722,6 +2721,7 @@ export interface Query {
findApplicationRegistrationStats: ApplicationRegistrationStats
findApplicationRegistrationVariables: ApplicationRegistrationVariableDTO[]
applicationRegistrationTarballUrl?: Scalars['String']
getRoles: Role[]
previewMessageCampaignAudience: CampaignAudiencePreviewDTO
unsubscribeTopics: UnsubscribeTopic[]
unsubscribePagePreviewUrl: Scalars['String']
@@ -2891,6 +2891,15 @@ export interface Mutation {
destroyViewGroup: ViewGroup
runWorkspaceMigration: Scalars['Boolean']
uninstallApplication: Scalars['Boolean']
createApplicationRegistration: CreateApplicationRegistration
updateApplicationRegistration: ApplicationRegistration
deleteApplicationRegistration: Scalars['Boolean']
rotateApplicationRegistrationClientSecret: RotateClientSecret
createApplicationRegistrationVariable: ApplicationRegistrationVariable
updateApplicationRegistrationVariable: ApplicationRegistrationVariable
deleteApplicationRegistrationVariable: Scalars['Boolean']
uploadAppTarball: ApplicationRegistration
transferApplicationRegistrationOwnership: ApplicationRegistration
updateWorkspaceMemberRole: WorkspaceMember
createOneRole: Role
updateOneRole: Role
@@ -2901,15 +2910,6 @@ export interface Mutation {
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
assignRoleToAgent: Scalars['Boolean']
removeRoleFromAgent: Scalars['Boolean']
createApplicationRegistration: CreateApplicationRegistration
updateApplicationRegistration: ApplicationRegistration
deleteApplicationRegistration: Scalars['Boolean']
rotateApplicationRegistrationClientSecret: RotateClientSecret
createApplicationRegistrationVariable: ApplicationRegistrationVariable
updateApplicationRegistrationVariable: ApplicationRegistrationVariable
deleteApplicationRegistrationVariable: Scalars['Boolean']
uploadAppTarball: ApplicationRegistration
transferApplicationRegistrationOwnership: ApplicationRegistration
sendEmailViaEmailingDomain: SendEmailViaDomainOutput
sendMessageCampaign: SendMessageCampaignOutputDTO
createUnsubscribeTopic: UnsubscribeTopic
@@ -5885,7 +5885,6 @@ export interface QueryGenqlSelection{
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
findManyApplications?: ApplicationGenqlSelection
findOneApplication?: (ApplicationGenqlSelection & { __args?: {id?: (Scalars['UUID'] | null), universalIdentifier?: (Scalars['UUID'] | null)} })
getRoles?: RoleGenqlSelection
findApplicationRegistrationByClientId?: (PublicApplicationRegistrationGenqlSelection & { __args: {clientId: Scalars['String']} })
findApplicationRegistrationByUniversalIdentifier?: (ApplicationRegistrationGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
findManyApplicationRegistrations?: ApplicationRegistrationGenqlSelection
@@ -5893,6 +5892,7 @@ export interface QueryGenqlSelection{
findApplicationRegistrationStats?: (ApplicationRegistrationStatsGenqlSelection & { __args: {id: Scalars['String']} })
findApplicationRegistrationVariables?: (ApplicationRegistrationVariableDTOGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} })
applicationRegistrationTarballUrl?: { __args: {id: Scalars['String']} }
getRoles?: RoleGenqlSelection
previewMessageCampaignAudience?: (CampaignAudiencePreviewDTOGenqlSelection & { __args: {input: PreviewMessageCampaignAudienceInput} })
unsubscribeTopics?: UnsubscribeTopicGenqlSelection
unsubscribePagePreviewUrl?: boolean | number
@@ -6087,6 +6087,15 @@ export interface MutationGenqlSelection{
destroyViewGroup?: (ViewGroupGenqlSelection & { __args: {input: DestroyViewGroupInput} })
runWorkspaceMigration?: { __args: {workspaceMigration: WorkspaceMigrationInput} }
uninstallApplication?: { __args: {universalIdentifier: Scalars['String']} }
createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} })
updateApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {input: UpdateApplicationRegistrationInput} })
deleteApplicationRegistration?: { __args: {id: Scalars['String']} }
rotateApplicationRegistrationClientSecret?: (RotateClientSecretGenqlSelection & { __args: {id: Scalars['String']} })
createApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: CreateApplicationRegistrationVariableInput} })
updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} })
deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} }
uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} })
transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} })
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
@@ -6097,15 +6106,6 @@ export interface MutationGenqlSelection{
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} })
updateApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {input: UpdateApplicationRegistrationInput} })
deleteApplicationRegistration?: { __args: {id: Scalars['String']} }
rotateApplicationRegistrationClientSecret?: (RotateClientSecretGenqlSelection & { __args: {id: Scalars['String']} })
createApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: CreateApplicationRegistrationVariableInput} })
updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} })
deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} }
uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} })
transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} })
sendEmailViaEmailingDomain?: (SendEmailViaDomainOutputGenqlSelection & { __args: {input: SendEmailViaDomainInput} })
sendMessageCampaign?: (SendMessageCampaignOutputDTOGenqlSelection & { __args: {input: SendMessageCampaignInput} })
createUnsubscribeTopic?: (UnsubscribeTopicGenqlSelection & { __args: {input: CreateUnsubscribeTopicInput} })
@@ -6474,6 +6474,18 @@ export interface WorkspaceMigrationInput {actions: WorkspaceMigrationDeleteActio
export interface WorkspaceMigrationDeleteActionInput {type: WorkspaceMigrationActionType,metadataName: AllMetadataName,universalIdentifier: Scalars['String']}
export interface CreateApplicationRegistrationInput {name: Scalars['String'],universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null)}
export interface UpdateApplicationRegistrationInput {id: Scalars['String'],update: UpdateApplicationRegistrationPayload}
export interface UpdateApplicationRegistrationPayload {name?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),isListed?: (Scalars['Boolean'] | null),isPreInstalled?: (Scalars['Boolean'] | null)}
export interface CreateApplicationRegistrationVariableInput {applicationRegistrationId: Scalars['String'],key: Scalars['String'],value: Scalars['String'],description?: (Scalars['String'] | null),isSecret?: (Scalars['Boolean'] | null)}
export interface UpdateApplicationRegistrationVariableInput {id: Scalars['String'],update: UpdateApplicationRegistrationVariablePayload}
export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars['String'] | null),resetValue?: (Scalars['Boolean'] | null),description?: (Scalars['String'] | null)}
export interface CreateRoleInput {id?: (Scalars['String'] | null),label: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),canUpdateAllSettings?: (Scalars['Boolean'] | null),canAccessAllTools?: (Scalars['Boolean'] | null),canReadAllObjectRecords?: (Scalars['Boolean'] | null),canUpdateAllObjectRecords?: (Scalars['Boolean'] | null),canSoftDeleteAllObjectRecords?: (Scalars['Boolean'] | null),canDestroyAllObjectRecords?: (Scalars['Boolean'] | null),canBeAssignedToUsers?: (Scalars['Boolean'] | null),canBeAssignedToAgents?: (Scalars['Boolean'] | null),canBeAssignedToApiKeys?: (Scalars['Boolean'] | null)}
export interface UpdateRoleInput {update: UpdateRolePayload,
@@ -6498,18 +6510,6 @@ export interface RowLevelPermissionPredicateInput {id?: (Scalars['UUID'] | null)
export interface RowLevelPermissionPredicateGroupInput {id?: (Scalars['UUID'] | null),objectMetadataId: Scalars['UUID'],parentRowLevelPermissionPredicateGroupId?: (Scalars['UUID'] | null),logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator,positionInRowLevelPermissionPredicateGroup?: (Scalars['Float'] | null)}
export interface CreateApplicationRegistrationInput {name: Scalars['String'],universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null)}
export interface UpdateApplicationRegistrationInput {id: Scalars['String'],update: UpdateApplicationRegistrationPayload}
export interface UpdateApplicationRegistrationPayload {name?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null),isListed?: (Scalars['Boolean'] | null),isPreInstalled?: (Scalars['Boolean'] | null)}
export interface CreateApplicationRegistrationVariableInput {applicationRegistrationId: Scalars['String'],key: Scalars['String'],value: Scalars['String'],description?: (Scalars['String'] | null),isSecret?: (Scalars['Boolean'] | null)}
export interface UpdateApplicationRegistrationVariableInput {id: Scalars['String'],update: UpdateApplicationRegistrationVariablePayload}
export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars['String'] | null),resetValue?: (Scalars['Boolean'] | null),description?: (Scalars['String'] | null)}
export interface SendEmailViaDomainInput {emailingDomainId: Scalars['String'],to: Scalars['String'][],cc?: (Scalars['String'][] | null),bcc?: (Scalars['String'][] | null),subject: Scalars['String'],text: Scalars['String'],html?: (Scalars['String'] | null),from: Scalars['String'],replyTo?: (Scalars['String'][] | null)}
export interface SendMessageCampaignInput {listId: Scalars['String'],unsubscribeTopicId?: (Scalars['String'] | null),subject: Scalars['String'],body: Scalars['String'],fromAddress: Scalars['String']}
@@ -6400,9 +6400,6 @@ export default {
]
}
],
"getRoles": [
29
],
"findApplicationRegistrationByClientId": [
199,
{
@@ -6460,6 +6457,9 @@ export default {
]
}
],
"getRoles": [
29
],
"previewMessageCampaignAudience": [
281,
{
@@ -7994,109 +7994,11 @@ export default {
]
}
],
"updateWorkspaceMemberRole": [
20,
{
"workspaceMemberId": [
3,
"UUID!"
],
"roleId": [
3,
"UUID!"
]
}
],
"createOneRole": [
29,
{
"createRoleInput": [
443,
"CreateRoleInput!"
]
}
],
"updateOneRole": [
29,
{
"updateRoleInput": [
444,
"UpdateRoleInput!"
]
}
],
"deleteOneRole": [
1,
{
"roleId": [
3,
"UUID!"
]
}
],
"upsertObjectPermissions": [
16,
{
"upsertObjectPermissionsInput": [
446,
"UpsertObjectPermissionsInput!"
]
}
],
"upsertPermissionFlags": [
27,
{
"upsertPermissionFlagsInput": [
448,
"UpsertPermissionFlagsInput!"
]
}
],
"upsertFieldPermissions": [
26,
{
"upsertFieldPermissionsInput": [
449,
"UpsertFieldPermissionsInput!"
]
}
],
"upsertRowLevelPermissionPredicates": [
232,
{
"input": [
451,
"UpsertRowLevelPermissionPredicatesInput!"
]
}
],
"assignRoleToAgent": [
6,
{
"agentId": [
3,
"UUID!"
],
"roleId": [
3,
"UUID!"
]
}
],
"removeRoleFromAgent": [
6,
{
"agentId": [
3,
"UUID!"
]
}
],
"createApplicationRegistration": [
198,
{
"input": [
454,
443,
"CreateApplicationRegistrationInput!"
]
}
@@ -8105,7 +8007,7 @@ export default {
8,
{
"input": [
455,
444,
"UpdateApplicationRegistrationInput!"
]
}
@@ -8132,7 +8034,7 @@ export default {
5,
{
"input": [
457,
446,
"CreateApplicationRegistrationVariableInput!"
]
}
@@ -8141,7 +8043,7 @@ export default {
5,
{
"input": [
458,
447,
"UpdateApplicationRegistrationVariableInput!"
]
}
@@ -8180,6 +8082,104 @@ export default {
]
}
],
"updateWorkspaceMemberRole": [
20,
{
"workspaceMemberId": [
3,
"UUID!"
],
"roleId": [
3,
"UUID!"
]
}
],
"createOneRole": [
29,
{
"createRoleInput": [
449,
"CreateRoleInput!"
]
}
],
"updateOneRole": [
29,
{
"updateRoleInput": [
450,
"UpdateRoleInput!"
]
}
],
"deleteOneRole": [
1,
{
"roleId": [
3,
"UUID!"
]
}
],
"upsertObjectPermissions": [
16,
{
"upsertObjectPermissionsInput": [
452,
"UpsertObjectPermissionsInput!"
]
}
],
"upsertPermissionFlags": [
27,
{
"upsertPermissionFlagsInput": [
454,
"UpsertPermissionFlagsInput!"
]
}
],
"upsertFieldPermissions": [
26,
{
"upsertFieldPermissionsInput": [
455,
"UpsertFieldPermissionsInput!"
]
}
],
"upsertRowLevelPermissionPredicates": [
232,
{
"input": [
457,
"UpsertRowLevelPermissionPredicatesInput!"
]
}
],
"assignRoleToAgent": [
6,
{
"agentId": [
3,
"UUID!"
],
"roleId": [
3,
"UUID!"
]
}
],
"removeRoleFromAgent": [
6,
{
"agentId": [
3,
"UUID!"
]
}
],
"sendEmailViaEmailingDomain": [
282,
{
@@ -10950,6 +10950,99 @@ export default {
]
},
"WorkspaceMigrationActionType": {},
"CreateApplicationRegistrationInput": {
"name": [
1
],
"universalIdentifier": [
1
],
"oAuthRedirectUris": [
1
],
"oAuthScopes": [
1
],
"__typename": [
1
]
},
"UpdateApplicationRegistrationInput": {
"id": [
1
],
"update": [
445
],
"__typename": [
1
]
},
"UpdateApplicationRegistrationPayload": {
"name": [
1
],
"oAuthRedirectUris": [
1
],
"oAuthScopes": [
1
],
"isListed": [
6
],
"isPreInstalled": [
6
],
"__typename": [
1
]
},
"CreateApplicationRegistrationVariableInput": {
"applicationRegistrationId": [
1
],
"key": [
1
],
"value": [
1
],
"description": [
1
],
"isSecret": [
6
],
"__typename": [
1
]
},
"UpdateApplicationRegistrationVariableInput": {
"id": [
1
],
"update": [
448
],
"__typename": [
1
]
},
"UpdateApplicationRegistrationVariablePayload": {
"value": [
1
],
"resetValue": [
6
],
"description": [
1
],
"__typename": [
1
]
},
"CreateRoleInput": {
"id": [
1
@@ -10996,7 +11089,7 @@ export default {
},
"UpdateRoleInput": {
"update": [
445
451
],
"id": [
3
@@ -11051,7 +11144,7 @@ export default {
3
],
"objectPermissions": [
447
453
],
"__typename": [
1
@@ -11093,7 +11186,7 @@ export default {
3
],
"fieldPermissions": [
450
456
],
"__typename": [
1
@@ -11124,10 +11217,10 @@ export default {
3
],
"predicates": [
452
458
],
"predicateGroups": [
453
459
],
"__typename": [
1
@@ -11185,99 +11278,6 @@ export default {
1
]
},
"CreateApplicationRegistrationInput": {
"name": [
1
],
"universalIdentifier": [
1
],
"oAuthRedirectUris": [
1
],
"oAuthScopes": [
1
],
"__typename": [
1
]
},
"UpdateApplicationRegistrationInput": {
"id": [
1
],
"update": [
456
],
"__typename": [
1
]
},
"UpdateApplicationRegistrationPayload": {
"name": [
1
],
"oAuthRedirectUris": [
1
],
"oAuthScopes": [
1
],
"isListed": [
6
],
"isPreInstalled": [
6
],
"__typename": [
1
]
},
"CreateApplicationRegistrationVariableInput": {
"applicationRegistrationId": [
1
],
"key": [
1
],
"value": [
1
],
"description": [
1
],
"isSecret": [
6
],
"__typename": [
1
]
},
"UpdateApplicationRegistrationVariableInput": {
"id": [
1
],
"update": [
459
],
"__typename": [
1
]
},
"UpdateApplicationRegistrationVariablePayload": {
"value": [
1
],
"resetValue": [
6
],
"description": [
1
],
"__typename": [
1
]
},
"SendEmailViaDomainInput": {
"emailingDomainId": [
1
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationManifestModule } from 'src/engine/core-modules/application/application-manifest/application-manifest.module';
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
@@ -19,6 +20,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
imports: [
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
ApplicationModule,
ApplicationRegistrationModule,
ApplicationManifestModule,
ApplicationPackageModule,
CacheLockModule,
@@ -15,7 +15,9 @@ import {
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
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 { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ManifestAssetUrlResolverService } from 'src/engine/core-modules/application/application-registration/manifest-asset-url-resolver.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
@@ -55,6 +57,7 @@ export class ApplicationInstallService {
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly applicationService: ApplicationService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
private readonly applicationSyncService: ApplicationSyncService,
@@ -65,6 +68,7 @@ export class ApplicationInstallService {
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
private readonly messageQueueService: MessageQueueService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly manifestAssetUrlResolverService: ManifestAssetUrlResolverService,
) {}
async installApplication(params: {
@@ -270,6 +274,12 @@ export class ApplicationInstallService {
universalIdentifier,
});
await this.refreshRegistrationFromInstall({
appRegistration,
manifest: resolvedPackage.manifest,
installedVersion: newVersion,
});
this.logger.log(
`Successfully installed app ${universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
);
@@ -297,6 +307,26 @@ export class ApplicationInstallService {
}
}
private async refreshRegistrationFromInstall(params: {
appRegistration: ApplicationRegistrationEntity;
manifest: Manifest;
installedVersion: string;
}): Promise<void> {
const { appRegistration, manifest, installedVersion } = params;
await this.applicationRegistrationService.updateFromManifest({
applicationRegistrationId: appRegistration.id,
manifest: this.manifestAssetUrlResolverService.resolveFromRegistration({
sourceType: appRegistration.sourceType,
sourcePackage: appRegistration.sourcePackage,
manifest,
version: installedVersion,
}),
latestAvailableVersion: installedVersion,
preventVersionDowngrade: true,
});
}
private async runPreInstallHook(params: {
manifest: Manifest;
workspaceId: string;
@@ -0,0 +1,57 @@
import { shouldRefreshApplicationRegistrationOnInstall } from 'src/engine/core-modules/application/application-install/utils/should-refresh-application-registration-on-install.util';
describe('shouldRefreshApplicationRegistrationOnInstall', () => {
it('should refresh when latestAvailableVersion is null', () => {
expect(
shouldRefreshApplicationRegistrationOnInstall({
installedVersion: '1.0.0',
latestAvailableVersion: null,
}),
).toBe(true);
});
it('should refresh when latestAvailableVersion is not a valid semver', () => {
expect(
shouldRefreshApplicationRegistrationOnInstall({
installedVersion: '1.0.0',
latestAvailableVersion: 'not-a-version',
}),
).toBe(true);
});
it('should refresh when installed version is newer than latestAvailableVersion', () => {
expect(
shouldRefreshApplicationRegistrationOnInstall({
installedVersion: '2.0.0',
latestAvailableVersion: '1.0.0',
}),
).toBe(true);
});
it('should refresh when installed version equals latestAvailableVersion', () => {
expect(
shouldRefreshApplicationRegistrationOnInstall({
installedVersion: '1.2.3',
latestAvailableVersion: '1.2.3',
}),
).toBe(true);
});
it('should not refresh when installed version is older than latestAvailableVersion', () => {
expect(
shouldRefreshApplicationRegistrationOnInstall({
installedVersion: '1.0.0',
latestAvailableVersion: '2.0.0',
}),
).toBe(false);
});
it('should not refresh when installed version is not a valid semver and latestAvailableVersion is valid', () => {
expect(
shouldRefreshApplicationRegistrationOnInstall({
installedVersion: 'not-a-version',
latestAvailableVersion: '1.0.0',
}),
).toBe(false);
});
});
@@ -0,0 +1,23 @@
import semver from 'semver';
import { isDefined } from 'twenty-shared/utils';
export const shouldRefreshApplicationRegistrationOnInstall = ({
installedVersion,
latestAvailableVersion,
}: {
installedVersion: string;
latestAvailableVersion: string | null;
}): boolean => {
if (
!isDefined(latestAvailableVersion) ||
!isDefined(semver.valid(latestAvailableVersion))
) {
return true;
}
if (!isDefined(semver.valid(installedVersion))) {
return false;
}
return semver.gte(installedVersion, latestAvailableVersion);
};
@@ -3,9 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ManifestAssetUrlResolverService } from 'src/engine/core-modules/application/application-registration/manifest-asset-url-resolver.service';
@Injectable()
export class MarketplaceCatalogSyncService {
@@ -14,7 +12,7 @@ export class MarketplaceCatalogSyncService {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly marketplaceService: MarketplaceService,
private readonly twentyConfigService: TwentyConfigService,
private readonly manifestAssetUrlResolverService: ManifestAssetUrlResolverService,
) {}
async syncCatalog(): Promise<void> {
@@ -45,18 +43,12 @@ export class MarketplaceCatalogSyncService {
const universalIdentifier =
fetchedManifest.application.universalIdentifier;
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
fetchedManifest,
(filePath) =>
buildRegistryCdnUrl({
cdnBaseUrl,
packageName: pkg.name,
version: pkg.version,
filePath,
}),
);
const manifestWithResolvedUrls =
this.manifestAssetUrlResolverService.resolveFromRegistrySource({
manifest: fetchedManifest,
packageName: pkg.name,
version: pkg.version,
});
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier,
@@ -6,9 +6,11 @@ import { ApplicationRegistrationResolver } from 'src/engine/core-modules/applica
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ManifestAssetUrlResolverService } from 'src/engine/core-modules/application/application-registration/manifest-asset-url-resolver.service';
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
@@ -27,6 +29,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ApplicationRegistrationVariableModule,
ApplicationModule,
ApplicationPackageModule,
CacheLockModule,
DomainServerConfigModule,
FeatureFlagModule,
PermissionsModule,
@@ -38,10 +41,12 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ApplicationRegistrationService,
ApplicationRegistrationResolver,
ApplicationTarballService,
ManifestAssetUrlResolverService,
],
exports: [
ApplicationRegistrationService,
ApplicationRegistrationVariableModule,
ManifestAssetUrlResolverService,
],
})
export class ApplicationRegistrationModule {}
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
@@ -10,6 +10,8 @@ import { ILike, type FindOptionsWhere, type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
import { shouldRefreshApplicationRegistrationOnInstall } from 'src/engine/core-modules/application/application-install/utils/should-refresh-application-registration-on-install.util';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant';
import {
@@ -79,6 +81,8 @@ export type ApplicationRegistrationCatalogCard = {
@Injectable()
export class ApplicationRegistrationService {
private readonly logger = new Logger(ApplicationRegistrationService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@@ -87,6 +91,7 @@ export class ApplicationRegistrationService {
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly cacheLockService: CacheLockService,
) {}
async findMany(
@@ -286,22 +291,47 @@ export class ApplicationRegistrationService {
applicationRegistrationId,
manifest,
sourceType,
latestAvailableVersion,
preventVersionDowngrade = false,
}: {
applicationRegistrationId: string;
manifest: Manifest;
sourceType?: ApplicationRegistrationSourceType;
latestAvailableVersion?: string;
preventVersionDowngrade?: boolean;
}): Promise<void> {
const existing = await this.applicationRegistrationRepository.findOneOrFail(
{ where: { id: applicationRegistrationId } },
);
await this.cacheLockService.withLock(async () => {
const existing =
await this.applicationRegistrationRepository.findOneOrFail({
where: { id: applicationRegistrationId },
});
await this.applicationRegistrationRepository.save({
...existing,
name: manifest.application.displayName,
manifest,
...fromManifestApplicationToDisplayFields(manifest.application),
...(sourceType !== undefined && { sourceType }),
});
if (
preventVersionDowngrade &&
isDefined(latestAvailableVersion) &&
!shouldRefreshApplicationRegistrationOnInstall({
installedVersion: latestAvailableVersion,
latestAvailableVersion: existing.latestAvailableVersion,
})
) {
this.logger.log(
`Skipping registration update for ${existing.universalIdentifier}: version ${latestAvailableVersion} is older than latest available version ${existing.latestAvailableVersion}`,
);
return;
}
await this.applicationRegistrationRepository.save({
...existing,
name: manifest.application.displayName,
manifest,
...fromManifestApplicationToDisplayFields(manifest.application),
...(sourceType !== undefined && { sourceType }),
...(latestAvailableVersion !== undefined && {
latestAvailableVersion,
}),
});
}, `application-registration-update:${applicationRegistrationId}`);
}
async delete(id: string, ownerWorkspaceId: string): Promise<boolean> {
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class ManifestAssetUrlResolverService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
resolveFromRegistrySource({
manifest,
packageName,
version,
}: {
manifest: Manifest;
packageName: string;
version: string;
}): Manifest {
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
return resolveManifestAssetUrls(manifest, (filePath) =>
buildRegistryCdnUrl({ cdnBaseUrl, packageName, version, filePath }),
);
}
resolveFromRegistration({
sourceType,
sourcePackage,
manifest,
version,
}: {
sourceType: ApplicationRegistrationSourceType;
sourcePackage: string | null;
manifest: Manifest;
version: string;
}): Manifest {
if (
sourceType !== ApplicationRegistrationSourceType.NPM ||
!isDefined(sourcePackage)
) {
return manifest;
}
return this.resolveFromRegistrySource({
manifest,
packageName: sourcePackage,
version,
});
}
}