diff --git a/.github/workflows/ci-create-app.yaml b/.github/workflows/ci-create-app.yaml index 9204c70b3ca..ab7d8a497b6 100644 --- a/.github/workflows/ci-create-app.yaml +++ b/.github/workflows/ci-create-app.yaml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - task: [lint, typecheck, test, build] + task: [lint, typecheck, test] steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.11.0 @@ -39,6 +39,8 @@ jobs: fetch-depth: 0 - name: Install dependencies uses: ./.github/workflows/actions/yarn-install + - name: Build + run: npx nx build create-twenty-app - name: Run ${{ matrix.task }} task uses: ./.github/workflows/actions/nx-affected with: diff --git a/.github/workflows/ci-sdk.yaml b/.github/workflows/ci-sdk.yaml index 1b24af1dc3f..0c779397806 100644 --- a/.github/workflows/ci-sdk.yaml +++ b/.github/workflows/ci-sdk.yaml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - task: [lint, typecheck, test, build] + task: [lint, typecheck, test] steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.11.0 @@ -39,6 +39,8 @@ jobs: fetch-depth: 0 - name: Install dependencies uses: ./.github/workflows/actions/yarn-install + - name: Build + run: npx nx build twenty-sdk - name: Run ${{ matrix.task }} task uses: ./.github/workflows/actions/nx-affected with: diff --git a/packages/twenty-apps/hello-world/package.json b/packages/twenty-apps/hello-world/package.json index 1125b7b5f79..33da37bbc3b 100644 --- a/packages/twenty-apps/hello-world/package.json +++ b/packages/twenty-apps/hello-world/package.json @@ -1,6 +1,6 @@ { "name": "hello-world", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "engines": { "node": "^24.5.0", @@ -17,7 +17,7 @@ "auth": "twenty auth login" }, "dependencies": { - "twenty-sdk": "0.1.2" + "twenty-sdk": "0.2.0" }, "devDependencies": { "@types/node": "^24.7.2" diff --git a/packages/twenty-apps/hello-world/src/actions/create-new-post-card.ts b/packages/twenty-apps/hello-world/src/actions/create-new-post-card.ts index 40aa40e609d..627ecc3edff 100644 --- a/packages/twenty-apps/hello-world/src/actions/create-new-post-card.ts +++ b/packages/twenty-apps/hello-world/src/actions/create-new-post-card.ts @@ -1,21 +1,18 @@ import { type FunctionConfig } from 'twenty-sdk'; -import { createClient } from '../../generated'; +import Twenty from '../../generated'; export const main = async (params: { recipient?: string }) => { try { - const client = createClient({ - url: `${process.env.TWENTY_API_URL}/graphql`, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${process.env.TWENTY_API_KEY}`, - }, - }); + const client = new Twenty(); const createPostCard = await client.mutation({ createPostCard: { __args: { data: { - name: params.recipient ?? 'Hello-world', + name: + params.recipient ?? + process.env.DEFAULT_RECIPIENT_NAME ?? + 'Hello world', }, }, name: true, diff --git a/packages/twenty-apps/hello-world/application.config.ts b/packages/twenty-apps/hello-world/src/application.config.ts similarity index 54% rename from packages/twenty-apps/hello-world/application.config.ts rename to packages/twenty-apps/hello-world/src/application.config.ts index 75037fdf551..eb3e9655aff 100644 --- a/packages/twenty-apps/hello-world/application.config.ts +++ b/packages/twenty-apps/hello-world/src/application.config.ts @@ -6,17 +6,14 @@ const config: ApplicationConfig = { description: 'A simple hello world app', icon: 'IconWorld', applicationVariables: { - TWENTY_API_KEY: { - universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305', - description: 'Twenty API Key', - isSecret: true, - }, - TWENTY_API_URL: { - universalIdentifier: 'ef8ab489-e68a-4841-b402-261f440e6185', - description: 'Twenty API Url', + DEFAULT_RECIPIENT_NAME: { + universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de', + description: 'Default recipient name for postcards', + value: 'Alex Karp', isSecret: false, }, }, + functionRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', }; export default config; diff --git a/packages/twenty-apps/hello-world/src/roles/function-role.ts b/packages/twenty-apps/hello-world/src/roles/function-role.ts new file mode 100644 index 00000000000..82e89d4d48c --- /dev/null +++ b/packages/twenty-apps/hello-world/src/roles/function-role.ts @@ -0,0 +1,34 @@ +import { PermissionFlag, type RoleConfig } from 'twenty-sdk'; + +export const functionRole: RoleConfig = { + universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', + label: 'Default function role', + description: 'Default role for function Twenty client', + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canBeAssignedToAgents: false, + canBeAssignedToUsers: false, + canBeAssignedToApiKeys: false, + canBeAssignedToApplications: true, + objectPermissions: [ + { + objectNameSingular: 'postCard', + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + fieldPermissions: [ + { + objectNameSingular: 'postCard', + fieldName: 'content', + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + permissionFlags: [PermissionFlag.APPLICATIONS], +}; diff --git a/packages/twenty-front/nyc.config.cjs b/packages/twenty-front/nyc.config.cjs index 4ca921c13c1..d9961902b06 100644 --- a/packages/twenty-front/nyc.config.cjs +++ b/packages/twenty-front/nyc.config.cjs @@ -11,7 +11,7 @@ const modulesCoverage = { branches: 25, statements: 43, lines: 44, - functions: 38, + functions: 37, include: ['src/modules/**/*'], exclude: ['src/**/*.ts'], }; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index ed181f1c7ce..bf909eb0372 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -241,6 +241,8 @@ export type Application = { agents: Array; applicationVariables: Array; canBeUninstalled: Scalars['Boolean']; + defaultServerlessFunctionRole?: Maybe; + defaultServerlessFunctionRoleId?: Maybe; description: Scalars['String']; id: Scalars['UUID']; name: Scalars['String']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 175fa6e7e50..40a20211e61 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -241,6 +241,8 @@ export type Application = { agents: Array; applicationVariables: Array; canBeUninstalled: Scalars['Boolean']; + defaultServerlessFunctionRole?: Maybe; + defaultServerlessFunctionRoleId?: Maybe; description: Scalars['String']; id: Scalars['UUID']; name: Scalars['String']; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useActorFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useActorFieldDisplay.ts index e439c5ba8f4..777ed92b076 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useActorFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useActorFieldDisplay.ts @@ -36,6 +36,7 @@ export const useActorFieldDisplay = (): ActorFieldDisplayValue | undefined => { ].find( (workspaceMember) => workspaceMember.id === fieldValue.workspaceMemberId, ); + if (!isDefined(relatedWorkspaceMember)) { return { fieldValue, diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts index 9a86da65522..234779a272a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts @@ -284,6 +284,7 @@ const FieldActorSourceSchema = z.union([ z.literal('WORKFLOW'), z.literal('WEBHOOK'), z.literal('AGENT'), + z.literal('APPLICATION'), ]); export const FieldActorValueSchema = z.object({ diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx index ab36660e0ec..224edf279f3 100644 --- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -25,10 +25,10 @@ import { IconServer, IconSettings, IconSparkles, - IconPuzzle2, IconUserCircle, IconUsers, IconWorld, + IconPlug, } from 'twenty-ui/display'; import { FeatureFlagKey, PermissionFlagType } from '~/generated/graphql'; @@ -157,7 +157,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => { { label: t`Applications`, path: SettingsPath.Applications, - Icon: IconPuzzle2, + Icon: IconPlug, isHidden: !isApplicationEnabled || !permissionMap[PermissionFlagType.WORKSPACE], diff --git a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx index 2d66b18645c..dadbf35aac2 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx @@ -12,6 +12,7 @@ import { IconMail, IconMicrosoftCalendar, IconMicrosoftOutlook, + IconPlug, IconRobot, IconSettingsAutomation, IconWebhook, @@ -62,6 +63,8 @@ export const ActorDisplay = ({ return IconSettingsAutomation; case 'WEBHOOK': return IconWebhook; + case 'APPLICATION': + return IconPlug; default: return undefined; } diff --git a/packages/twenty-sdk/src/application/application-config.ts b/packages/twenty-sdk/src/application/application-config.ts index 2671a8f60cf..670199b88da 100644 --- a/packages/twenty-sdk/src/application/application-config.ts +++ b/packages/twenty-sdk/src/application/application-config.ts @@ -1,14 +1,3 @@ -import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type'; +import { type Application } from 'twenty-shared/application'; -type ApplicationVariable = SyncableEntityOptions & { - value?: string; - description?: string; - isSecret?: boolean; -}; - -export type ApplicationConfig = SyncableEntityOptions & { - displayName?: string; - description?: string; - icon?: string; - applicationVariables?: Record; -}; +export type ApplicationConfig = Application; diff --git a/packages/twenty-sdk/src/application/fields/field.decorator.ts b/packages/twenty-sdk/src/application/fields/field.decorator.ts index 324f2ef06e1..917b12a711a 100644 --- a/packages/twenty-sdk/src/application/fields/field.decorator.ts +++ b/packages/twenty-sdk/src/application/fields/field.decorator.ts @@ -1,31 +1,8 @@ -import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type'; - -import { - type FieldMetadataType, - type FieldMetadataSettings, - type FieldMetadataOptions, - type FieldMetadataDefaultValue, -} from 'twenty-shared/types'; - -type FieldOptions< - T extends FieldMetadataType = Exclude< - FieldMetadataType, - // Use @WorkspaceRelation or @WorkspaceDynamicRelation for relation fields - FieldMetadataType.RELATION - >, -> = SyncableEntityOptions & { - type: T; - label: string; - description?: string; - icon?: string; - defaultValue?: FieldMetadataDefaultValue; - options?: FieldMetadataOptions; - settings?: FieldMetadataSettings; - isNullable?: boolean; -}; +import { type FieldMetadataType } from 'twenty-shared/types'; +import { type FieldManifest } from 'twenty-shared/application'; export const Field = ( - _: FieldOptions, + _: FieldManifest, ): PropertyDecorator => { return () => {}; }; diff --git a/packages/twenty-sdk/src/application/function-config.ts b/packages/twenty-sdk/src/application/function-config.ts index 4a1f183ff22..d28cd300292 100644 --- a/packages/twenty-sdk/src/application/function-config.ts +++ b/packages/twenty-sdk/src/application/function-config.ts @@ -1,28 +1,14 @@ -import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type'; +import { + type ServerlessFunctionManifest, + type ServerlessFunctionTriggerManifest, +} from 'twenty-shared/application'; -type RouteTrigger = { - type: 'route'; - path: string; - httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - isAuthRequired: boolean; -}; - -type CronTrigger = { - type: 'cron'; - pattern: string; -}; - -type DatabaseEventTrigger = { - type: 'databaseEvent'; - eventName: string; -}; - -type FunctionTrigger = SyncableEntityOptions & - (RouteTrigger | CronTrigger | DatabaseEventTrigger); - -export type FunctionConfig = SyncableEntityOptions & { +export type FunctionConfig = Omit< + ServerlessFunctionManifest, + 'handlerPath' | 'handlerName' +> & { name?: string; description?: string; timeoutSeconds?: number; - triggers?: FunctionTrigger[]; + triggers?: ServerlessFunctionTriggerManifest[]; }; diff --git a/packages/twenty-sdk/src/application/index.ts b/packages/twenty-sdk/src/application/index.ts index c1e7dd49129..9cc820e6678 100644 --- a/packages/twenty-sdk/src/application/index.ts +++ b/packages/twenty-sdk/src/application/index.ts @@ -26,4 +26,6 @@ export { Relation } from './fields/relation.decorator'; export type { FunctionConfig } from './function-config'; export { Object } from './objects/object.decorator'; export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids'; +export { PermissionFlag } from './permission-flag-type'; +export type { RoleConfig } from './role-config'; export type { SyncableEntityOptions } from './syncable-entity-options.type'; diff --git a/packages/twenty-sdk/src/application/objects/object.decorator.ts b/packages/twenty-sdk/src/application/objects/object.decorator.ts index 1b6d2d1ef2d..49f2da656e5 100644 --- a/packages/twenty-sdk/src/application/objects/object.decorator.ts +++ b/packages/twenty-sdk/src/application/objects/object.decorator.ts @@ -1,13 +1,6 @@ -import { type SyncableEntityOptions } from '@/application/syncable-entity-options.type'; +import { type ObjectManifest } from 'twenty-shared/application'; -type ObjectMetadataOptions = SyncableEntityOptions & { - nameSingular: string; - namePlural: string; - labelSingular: string; - labelPlural: string; - description?: string; - icon?: string; -}; +type ObjectMetadataOptions = Omit; export const Object = (_: ObjectMetadataOptions): ClassDecorator => { return () => {}; diff --git a/packages/twenty-sdk/src/application/permission-flag-type.ts b/packages/twenty-sdk/src/application/permission-flag-type.ts new file mode 100644 index 00000000000..b5f9d27a5e4 --- /dev/null +++ b/packages/twenty-sdk/src/application/permission-flag-type.ts @@ -0,0 +1 @@ +export { PermissionFlagType as PermissionFlag } from 'twenty-shared/constants'; diff --git a/packages/twenty-sdk/src/application/role-config.ts b/packages/twenty-sdk/src/application/role-config.ts new file mode 100644 index 00000000000..ef02c7b3055 --- /dev/null +++ b/packages/twenty-sdk/src/application/role-config.ts @@ -0,0 +1,3 @@ +import type { RoleManifest } from 'twenty-shared/application'; + +export type RoleConfig = RoleManifest; diff --git a/packages/twenty-sdk/src/cli/services/generate.service.ts b/packages/twenty-sdk/src/cli/services/generate.service.ts index 2fcc6b90f99..9162a723c66 100644 --- a/packages/twenty-sdk/src/cli/services/generate.service.ts +++ b/packages/twenty-sdk/src/cli/services/generate.service.ts @@ -3,6 +3,11 @@ import chalk from 'chalk'; import { join, resolve } from 'path'; import { ApiService } from '@/cli/services/api.service'; import { ConfigService } from '@/cli/services/config.service'; +import * as fs from 'fs-extra'; +import { + DEFAULT_API_URL_NAME, + DEFAULT_API_KEY_NAME, +} from 'twenty-shared/application'; export const GENERATED_FOLDER_NAME = 'generated'; @@ -39,23 +44,73 @@ export class GenerateService { console.log(chalk.gray(`Output: ${outputPath}`)); const getSchemaResponse = await this.apiService.getSchema(); + if (!getSchemaResponse.success) { return; } + const { data: schema } = getSchemaResponse; + const output = resolve(outputPath); + await generate({ schema, - output: resolve(outputPath), + output, scalarTypes: { DateTime: 'string', JSON: 'Record', UUID: 'string', }, - verbose: true, }); + await this.injectTwentyClient(output); + console.log(chalk.green('✓ Client generated successfully!')); console.log(chalk.gray(`Generated files at: ${outputPath}`)); } + + private async injectTwentyClient(output: string) { + const twentyClientContent = ` + +// ---------------------------------------------------- +// ✨ Custom Twenty client (auto-injected) +// ---------------------------------------------------- + +const defaultOptions: ClientOptions = { + url: \`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\`, + headers: { + 'Content-Type': 'application/json', + Authorization: \`Bearer \${process.env.${DEFAULT_API_KEY_NAME}}\`, + }, +} + +export default class Twenty { + private client: Client; + + constructor(options?: ClientOptions) { + const merged: ClientOptions = { + ...defaultOptions, + ...options, + headers: { + ...defaultOptions.headers, + ...(options?.headers ?? {}), + }, + }; + + this.client = createClient(merged); + } + + query(request: R & { __name?: string }) { + return this.client.query(request); + } + + mutation(request: R & { __name?: string }) { + return this.client.mutation(request); + } +} + +`; + + await fs.appendFile(join(output, 'index.ts'), twentyClientContent); + } } diff --git a/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts b/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts index ddf49a6065a..08a1e829c5c 100644 --- a/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts +++ b/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts @@ -2,7 +2,7 @@ import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { loadManifest } from '@/cli/utils/load-manifest'; -import { v4 } from 'uuid'; +import { type ApplicationConfig } from '@/application'; const write = (root: string, file: string, content: string) => { const abs = join(root, file); @@ -36,7 +36,51 @@ declare module 'twenty-sdk' { description?: string; icon?: string; applicationVariables?: Record; + functionRoleUniversalIdentifier?: string; }; + + export type RoleConfig = SyncableEntityOptions & { + label: string; + description?: string; + icon?: string; + canReadAllObjectRecords?: boolean; + canUpdateAllObjectRecords?: boolean; + canSoftDeleteAllObjectRecords?: boolean; + canDestroyAllObjectRecords?: boolean; + objectPermissions?: any[]; + fieldPermissions?: any[]; + permissionFlags?: any[]; + }; + + export enum PermissionFlag { + API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS', + WORKSPACE = 'WORKSPACE', + WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS', + ROLES = 'ROLES', + DATA_MODEL = 'DATA_MODEL', + SECURITY = 'SECURITY', + WORKFLOWS = 'WORKFLOWS', + IMPERSONATE = 'IMPERSONATE', + SSO_BYPASS = 'SSO_BYPASS', + APPLICATIONS = 'APPLICATIONS', + LAYOUTS = 'LAYOUTS', + BILLING = 'BILLING', + AI_SETTINGS = 'AI_SETTINGS', + + // Tool permissions + AI = 'AI', + VIEWS = 'VIEWS', + UPLOAD_FILE = 'UPLOAD_FILE', + DOWNLOAD_FILE = 'DOWNLOAD_FILE', + SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL', + HTTP_REQUEST_TOOL = 'HTTP_REQUEST_TOOL', + IMPORT_CSV = 'IMPORT_CSV', + EXPORT_CSV = 'EXPORT_CSV', + CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS', + PROFILE_INFORMATION = 'PROFILE_INFORMATION', + } + + type RouteTrigger = { type: 'route'; @@ -92,6 +136,39 @@ declare module 'twenty-sdk' { } `; +const defaultRoleMock = ` +import { PermissionFlag, type RoleConfig } from 'twenty-sdk'; + +export const functionRole: RoleConfig = { + universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', + label: 'hello-world-role', + description: 'A role to define app permissions', + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + objectPermissions: [ + { + objectNameSingular: 'postCard', + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + fieldPermissions: [ + { + objectNameSingular: 'postCard', + fieldName: 'content', + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + permissionFlags: [PermissionFlag.APPLICATIONS], +} + +`; + const serverlessFunctionMock = ` import { type FunctionConfig } from 'twenty-sdk'; @@ -273,14 +350,30 @@ const yarnLockMock = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIR # yarn lockfile v1 `; -const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk'; - -const config: ApplicationConfig = { - universalIdentifier: '${v4()}', +const applicationMockConfig: ApplicationConfig = { + universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe', displayName: 'My App', description: 'My app description', + icon: 'IconWorld', + applicationVariables: { + TWENTY_API_KEY: { + universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6', + description: 'Twenty API Key', + isSecret: true, + }, + TWENTY_API_URL: { + universalIdentifier: 'aa7210a6-75b0-46ca-bcbe-09a5b42a76ec', + description: 'Twenty API Url', + isSecret: false, + }, + }, + functionRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2', }; +const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk'; + +const config: ApplicationConfig = ${JSON.stringify(applicationMockConfig)}; + export default config; `; @@ -310,6 +403,8 @@ describe('loadManifest (integration)', () => { write(appDirectory, 'src/hello.ts', serverlessFunctionMock); + write(appDirectory, 'src/defaultRole.ts', defaultRoleMock); + write( appDirectory, 'src/types/twenty-sdk-application.d.ts', @@ -340,11 +435,7 @@ describe('loadManifest (integration)', () => { ); // application - const { universalIdentifier: _, ...otherInfo } = manifest.application; - expect(otherInfo).toEqual({ - displayName: 'My App', - description: 'My app description', - }); + expect(manifest.application).toEqual(applicationMockConfig); expect(manifest.objects.length).toBe(1); @@ -456,6 +547,31 @@ describe('loadManifest (integration)', () => { } } } + + //Role + expect(manifest.roles).toHaveLength(1); + + for (const role of manifest.roles ?? []) { + const { + universalIdentifier: _, + objectPermissions, + fieldPermissions, + permissionFlags, + ...otherInfo + } = role; + expect(otherInfo).toEqual({ + label: 'hello-world-role', + description: 'A role to define app permissions', + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + }); + + expect(Array.isArray(objectPermissions)).toBe(true); + expect(Array.isArray(fieldPermissions)).toBe(true); + expect(Array.isArray(permissionFlags)).toBe(true); + } }); it('should not define serverless for util file', async () => { @@ -482,6 +598,7 @@ export const format = async (params: any): Promise => { ]); expect(Object.keys(manifest.sources['src'])).toEqual([ 'Account.ts', + 'defaultRole.ts', 'hello.ts', ]); }); diff --git a/packages/twenty-sdk/src/cli/utils/load-manifest.ts b/packages/twenty-sdk/src/cli/utils/load-manifest.ts index 77780cff05a..15cd1805ce8 100644 --- a/packages/twenty-sdk/src/cli/utils/load-manifest.ts +++ b/packages/twenty-sdk/src/cli/utils/load-manifest.ts @@ -40,7 +40,7 @@ import { type ApplicationManifest, type ServerlessFunctionManifest, type ObjectManifest, - type FieldManifest, + type RoleManifest, } from 'twenty-shared/application'; import { findPathFile } from '@/cli/utils/find-path-file'; import { getTsProgramAndDiagnostics } from '@/cli/utils/get-ts-program-and-diagnostics'; @@ -185,7 +185,7 @@ const collectObjects = (program: Program) => { } fields.push({ - ...(fieldCfg as FieldManifest), + ...(fieldCfg as any), ...(name ? { name } : {}), }); } @@ -429,7 +429,9 @@ export const extractTwentyAppConfig = (program: Program): Application => { decl.initializer && isObjectLiteralExpression(decl.initializer) ) { - found = exprToValue(decl.initializer) as Application; + found = exprToValue( + decl.initializer, + ) as unknown as Application; } } } @@ -485,6 +487,39 @@ const isGeneratedModuleUsedInProgram = (program: Program): boolean => { return false; }; +export const collectRoles = (program: Program): Array => { + const roles: Array = []; + + for (const sf of program.getSourceFiles()) { + if (sf.isDeclarationFile) continue; + + for (const st of sf.statements) { + if (!isVariableStatement(st)) continue; + + // must be "export const ..." + const isExported = + st.modifiers?.some((m) => m.kind === SyntaxKind.ExportKeyword) ?? false; + if (!isExported) continue; + + for (const decl of st.declarationList.declarations) { + if (!isIdentifier(decl.name)) continue; + + // must be typed RoleConfig (matches: RoleConfig, foo.RoleConfig, import type RoleConfig, etc.) + const typeText = decl.type?.getText(sf) ?? ''; + if (!typeText.includes('RoleConfig')) continue; + + // must be "= { ... }" + const init = decl.initializer; + if (!init || !isObjectLiteralExpression(init)) continue; + + roles.push(exprToValue(init) as unknown as RoleManifest); + } + } + } + + return roles; +}; + export const loadManifest = async ( appPath: string, ): Promise<{ @@ -509,10 +544,11 @@ export const loadManifest = async ( diagnostics, }); - const [objects, serverlessFunctions, application, sources] = [ + const [objects, serverlessFunctions, application, roles, sources] = [ collectObjects(program), collectServerlessFunctions(program, appPath), extractTwentyAppConfig(program), + collectRoles(program), await loadFolderContentIntoJson(program, appPath), ]; @@ -525,6 +561,7 @@ export const loadManifest = async ( application, objects, serverlessFunctions, + roles, sources, }, shouldGenerate, diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index c58366460c0..22bb31c446f 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -1,6 +1,7 @@ import { type APP_LOCALES } from 'twenty-shared/translations'; import { type ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; +import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { type UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { type User } from 'src/engine/core-modules/user/user.entity'; @@ -11,6 +12,7 @@ declare module 'express-serve-static-core' { interface Request { user?: User | null; apiKey?: ApiKey | null; + application?: ApplicationEntity | null; userWorkspace?: UserWorkspace; locale: keyof typeof APP_LOCALES; workspace?: Workspace; diff --git a/packages/twenty-server/@types/jest.d.ts b/packages/twenty-server/@types/jest.d.ts index fe32702efc7..69ac0fd354e 100644 --- a/packages/twenty-server/@types/jest.d.ts +++ b/packages/twenty-server/@types/jest.d.ts @@ -15,7 +15,6 @@ declare module '@jest/types' { INVALID_ACCESS_TOKEN: string; APPLE_JONY_MEMBER_ACCESS_TOKEN: string; APPLE_PHIL_GUEST_ACCESS_TOKEN: string; - ACME_JONY_MEMBER_ACCESS_TOKEN: string; APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: string; API_KEY_ACCESS_TOKEN: string; testDataSource?: DataSource; @@ -32,8 +31,6 @@ declare global { const APPLE_PHIL_GUEST_ACCESS_TOKEN: string; const API_KEY_ACCESS_TOKEN: string; const APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: string; - const ACME_JONY_MEMBER_ACCESS_TOKEN: string; - const WORKSPACE_AGNOSTIC_TOKEN: string; // Additional global properties set during test setup var testDataSource: DataSource; diff --git a/packages/twenty-server/jest-integration.config.ts b/packages/twenty-server/jest-integration.config.ts index fe59d50db55..23c0f964a01 100644 --- a/packages/twenty-server/jest-integration.config.ts +++ b/packages/twenty-server/jest-integration.config.ts @@ -82,15 +82,13 @@ const jestConfig: JestConfigWithTsJest = { APPLE_JANE_ADMIN_ACCESS_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik', EXPIRED_ACCESS_TOKEN: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzM4MzIzODc5LCJleHAiOjE3MzgzMjU2Nzl9.m73hHVpnw5uGNGrSuKxn6XtKEUK3Wqkp4HsQdYfZiHo', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDgwNDkzLCJleHAiOjE3NjU0ODA1MDN9.H0rNZyYvaWsDqim8U0-knIpq-29EQ6ox9Eag4WpwZg8', INVALID_ACCESS_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzM4MzIzODc5LCJleHAiOjE3MzgzMjU2Nzl9.m73hHVpnw5uGNGrSuKxn6XtKEUK3Wqkp4HsQdYfZiHp', APPLE_JONY_MEMBER_ACCESS_TOKEN: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMzk1Ny00OTA4LTljMzYtMjkyOWEyM2Y4MzUzIiwiaWF0IjoxNzM5NDU5NTcwLCJleHAiOjMzMjk3MDU5NTcwfQ.Er7EEU4IP4YlGN79jCLR_6sUBqBfKx2M3G_qGiDpPRo', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ1c2VySWQiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMzk1Ny00OTA4LTljMzYtMjkyOWEyM2Y4MzUzIiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDY5OTgzLCJleHAiOjI3MTIxOTc5ODN9.B55MfSd3LShO9_61nvKHUzsSJD6XszEbFGn_76VpaKs', APPLE_PHIL_GUEST_ACCESS_TOKEN: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMTU1My00NWM2LWEwMjgtNWE5MDY0Y2NlMDdmIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtNzE2OS00MmNmLWJjNDctMWNmZWYxNTI2NGIxIiwiaWF0IjoxNzM5ODg4NDcwLCJleHAiOjMzMjk3NDg4NDcwfQ.0NEu-AWGv3l77rs-56Z5Gt0UTU7HDl6qUTHUcMWNrCc', - ACME_JONY_MEMBER_ACCESS_TOKEN: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ1c2VySWQiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjNiOGU2NDU4LTVmYzEtNGU2My04NTYzLTAwOGNjZGRhYTZkYiIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtZTEwYS00YzI3LWE5MGItYjA4YzU3YjAyZDQ1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUyMDc4MDA0LCJleHAiOjMzMzA5Njc4MDA0fQ.JBtQCkNWsqAkzouxhcVjCEikV6A_-qr3IflE67NYQYY', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ1c2VySWQiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMTU1My00NWM2LWEwMjgtNWE5MDY0Y2NlMDdmIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtNzE2OS00MmNmLWJjNDctMWNmZWYxNTI2NGIxIiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDcwOTczLCJleHAiOjI3MTIxOTg5NzN9.cd3CmyWiwDJEWD3VgVqG0JfXQ9w21y2eWx67vGPH9SI', API_KEY_ACCESS_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzQ0OTgzNzUwLCJleHAiOjQ4OTg1ODM2OTMsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.4xkkwz_uu2xzs_V8hJSaM15fGziT5zS3vq2lM48OHr0', APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN: diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-14/1-14-update-created-by-enum.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-14/1-14-update-created-by-enum.command.ts new file mode 100644 index 00000000000..37b879a32d8 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-14/1-14-update-created-by-enum.command.ts @@ -0,0 +1,93 @@ +import { Logger } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; +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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; + +@Command({ + name: 'upgrade:1-14:udpate-created-by-enum', + description: 'Add new APPLICATION value to createdBy enum', +}) +export class UpdateCreatedByEnumCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + protected readonly logger = new Logger(UpdateCreatedByEnumCommand.name); + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + dataSource, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun || false; + + if (!isDefined(dataSource)) { + throw new Error( + `Could not find data source for workspace ${workspaceId}, should never occur`, + ); + } + + const schemaName = getWorkspaceSchemaName(workspaceId); + + const queryRunner = this.coreDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + const createdByEnums = await queryRunner.query( + `SELECT t.typname + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typtype = 'e' + AND n.nspname = '${schemaName}' + AND t.typname LIKE '%\\_createdBySource\\_enum' ESCAPE '\\' + AND NOT EXISTS ( + SELECT 1 + FROM pg_enum e + WHERE e.enumtypid = t.oid + AND e.enumlabel = 'APPLICATION' + );`, + ); + + if (isDryRun) { + this.logger.log( + `Dry run mode: found ${createdByEnums.length} createdBy enums to update`, + ); + } + + if (!options.dryRun) { + this.logger.log(`Updating ${createdByEnums.length} createdBy enums`); + try { + for (const createdByEnum of createdByEnums) { + await queryRunner.query( + `ALTER TYPE "${schemaName}"."${createdByEnum.typname}" ADD VALUE 'APPLICATION'`, + ); + } + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.log( + `Rollbacking UpdateCreatedByEnumCommand: ${error.message}`, + ); + } finally { + await queryRunner.release(); + } + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-14/1-14-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-14/1-14-upgrade-version-command.module.ts new file mode 100644 index 00000000000..5aac1e3ad25 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-14/1-14-upgrade-version-command.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { UpdateCreatedByEnumCommand } from 'src/database/commands/upgrade-version-command/1-14/1-14-update-created-by-enum.command'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([WorkspaceEntity]), + DataSourceModule, + WorkspaceSchemaManagerModule, + ], + providers: [UpdateCreatedByEnumCommand], + exports: [UpdateCreatedByEnumCommand], +}) +export class V1_14_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts index f9b18eee0a7..b111fd7c119 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts @@ -5,11 +5,13 @@ import { V1_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { V1_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-14/1-14-upgrade-version-command.module'; @Module({ imports: [ TypeOrmModule.forFeature([WorkspaceEntity]), V1_13_UpgradeVersionCommandModule, + V1_14_UpgradeVersionCommandModule, DataSourceModule, ], providers: [UpgradeCommand], diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index ea049b5b79b..8ff4a33b88a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -21,6 +21,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { UpdateCreatedByEnumCommand } from 'src/database/commands/upgrade-version-command/1-14/1-14-update-created-by-enum.command'; @Command({ name: 'upgrade', @@ -45,6 +46,9 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly backfillViewMainGroupByFieldMetadataIdCommand: BackfillViewMainGroupByFieldMetadataIdCommand, protected readonly cleanEmptyStringNullInTextFieldsCommand: CleanEmptyStringNullInTextFieldsCommand, protected readonly renameIndexNameCommand: RenameIndexNameCommand, + + // 1.14 Commands + protected readonly updateCreatedByEnumCommand: UpdateCreatedByEnumCommand, ) { super( workspaceRepository, @@ -67,9 +71,12 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.renameIndexNameCommand, ]; + const commands_1140: VersionCommands = [this.updateCreatedByEnumCommand]; + this.allCommands = { '1.12.0': commands_1120, '1.13.0': commands_1130, + '1.14.0': commands_1140, }; } diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1765206100942-updateRoleColumns.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1765206100942-updateRoleColumns.ts new file mode 100644 index 00000000000..7a10e5e125e --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1765206100942-updateRoleColumns.ts @@ -0,0 +1,23 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class UpdateRoleColumns1765206100942 implements MigrationInterface { + name = 'UpdateRoleColumns1765206100942'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."roleTarget" DROP COLUMN "targetApplicationId"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."application" ADD "defaultServerlessFunctionRoleId" uuid`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" DROP COLUMN "defaultServerlessFunctionRoleId"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."roleTarget" ADD "targetApplicationId" uuid`, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts index 409aa649e72..038d9d5b431 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts @@ -49,6 +49,7 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import type { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; @Injectable() export abstract class CommonBaseQueryRunnerService< @@ -304,6 +305,10 @@ export abstract class CommonBaseQueryRunnerService< ); } + if (isDefined(authContext.application?.defaultServerlessFunctionRoleId)) { + return authContext.application?.defaultServerlessFunctionRoleId; + } + if (!isDefined(authContext.userWorkspaceId)) { throw new CommonQueryRunnerException( 'Invalid auth context', @@ -325,7 +330,9 @@ export abstract class CommonBaseQueryRunnerService< const roleId = await this.getRoleIdOrThrow(authContext, workspaceId); - const rolePermissionConfig = { unionOf: [roleId] }; + const rolePermissionConfig: RolePermissionConfig = { + intersectionOf: [roleId], + }; const repository = await this.globalWorkspaceOrmManager.getRepository( workspaceId, diff --git a/packages/twenty-server/src/engine/api/common/interfaces/workspace-auth-context.interface.ts b/packages/twenty-server/src/engine/api/common/interfaces/workspace-auth-context.interface.ts index 7556dcefd89..1d58c5e9741 100644 --- a/packages/twenty-server/src/engine/api/common/interfaces/workspace-auth-context.interface.ts +++ b/packages/twenty-server/src/engine/api/common/interfaces/workspace-auth-context.interface.ts @@ -2,6 +2,7 @@ import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-contex interface AuthContextWithDefinedWorkspaceProperties { user: AuthContext['user']; + application: AuthContext['application']; workspace: NonNullable; workspaceMetadataVersion?: string; workspaceMemberId: AuthContext['workspaceMemberId']; @@ -17,5 +18,9 @@ interface UserWorkspaceAuthContext extends Request { userWorkspaceId: NonNullable; } +interface ApplicationAuthContext extends Request { + application: NonNullable; +} + export type WorkspaceAuthContext = AuthContextWithDefinedWorkspaceProperties & - (ApiKeyAuthContext | UserWorkspaceAuthContext); + (ApiKeyAuthContext | UserWorkspaceAuthContext | ApplicationAuthContext); diff --git a/packages/twenty-server/src/engine/api/common/utils/is-workspace-auth-context.util.ts b/packages/twenty-server/src/engine/api/common/utils/is-workspace-auth-context.util.ts index c4c5e1d4558..6f43513ff09 100644 --- a/packages/twenty-server/src/engine/api/common/utils/is-workspace-auth-context.util.ts +++ b/packages/twenty-server/src/engine/api/common/utils/is-workspace-auth-context.util.ts @@ -9,6 +9,8 @@ export const isWorkspaceAuthContext = ( ): context is WorkspaceAuthContext => { return ( isDefined(context.workspace) && - (isDefined(context.userWorkspaceId) || isDefined(context.apiKey)) + (isDefined(context.userWorkspaceId) || + isDefined(context.apiKey) || + isDefined(context.application)) ); }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index 1c130fa23d5..abaf2db8819 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -115,6 +115,7 @@ export class GraphQLConfigService user, workspace, apiKey, + application, workspaceMemberId, userWorkspaceId, } = context.req; @@ -127,6 +128,7 @@ export class GraphQLConfigService user, workspace, apiKey, + application, workspaceMemberId, userWorkspaceId, }); diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts index f59d9a57a31..7973861697f 100644 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts +++ b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts @@ -76,10 +76,10 @@ export class RestApiMetadataService { } private getRequestContext(request: Request): RequestContext { - const baseUrl = getServerUrl( - this.twentyConfigService.get('SERVER_URL'), - `${request.protocol}://${request.get('host')}`, - ); + const baseUrl = getServerUrl({ + serverUrlEnv: this.twentyConfigService.get('SERVER_URL'), + serverUrlFallback: `${request.protocol}://${request.get('host')}`, + }); return { body: request.body, diff --git a/packages/twenty-server/src/engine/core-modules/actor/services/created-by-from-auth-context.service.ts b/packages/twenty-server/src/engine/core-modules/actor/services/created-by-from-auth-context.service.ts index 2e9e23dd685..16adc4e8d85 100644 --- a/packages/twenty-server/src/engine/core-modules/actor/services/created-by-from-auth-context.service.ts +++ b/packages/twenty-server/src/engine/core-modules/actor/services/created-by-from-auth-context.service.ts @@ -14,6 +14,7 @@ import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-module import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { buildCreatedByFromApplication } from 'src/engine/core-modules/actor/utils/build-created-by-from-application.util'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type CreateInput = Record; @@ -106,7 +107,7 @@ export class CreatedByFromAuthContextService { private async buildCreatedBy( authContext: AuthContext, ): Promise { - const { workspace, user, apiKey } = authContext; + const { workspace, user, apiKey, application } = authContext; assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); @@ -143,6 +144,12 @@ export class CreatedByFromAuthContextService { }); } + if (isDefined(application)) { + return buildCreatedByFromApplication({ + application, + }); + } + throw new Error( 'Unable to build createdBy metadata - no valid actor information found in auth context', ); diff --git a/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-application.util.ts b/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-application.util.ts new file mode 100644 index 00000000000..fe99d6c4ca1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-application.util.ts @@ -0,0 +1,15 @@ +import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types'; + +import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; + +type BuildCreatedByFromApplicationArgs = { + application: ApplicationEntity; +}; +export const buildCreatedByFromApplication = ({ + application, +}: BuildCreatedByFromApplicationArgs): ActorMetadata => ({ + source: FieldActorSource.APPLICATION, + name: application.name, + workspaceMemberId: null, + context: {}, +}); diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts index 6e59ee4b94b..77ea3dd752b 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts @@ -389,7 +389,7 @@ describe('ApiKeyService', () => { ); expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith( - JwtTokenTypeEnum.ACCESS, + JwtTokenTypeEnum.API_KEY, mockWorkspaceId, ); expect(mockJwtWrapperService.sign).toHaveBeenCalledWith( diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts index c3171ddbc5b..167400693c4 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts @@ -143,7 +143,7 @@ export class ApiKeyService { await this.validateApiKey(apiKeyId, workspaceId); const secret = this.jwtWrapperService.generateAppSecret( - JwtTokenTypeEnum.ACCESS, + JwtTokenTypeEnum.API_KEY, workspaceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts index 6c76fc50eee..1036618336a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts @@ -16,10 +16,10 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor'; import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module'; -import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module'; 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 { RoleModule } from 'src/engine/metadata-modules/role/role.module'; +import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; @Module({ imports: [ @@ -37,9 +37,9 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; WorkspaceMigrationV2Module, PermissionsModule, RoleModule, - RoleTargetModule, ObjectPermissionModule, PermissionFlagModule, + WorkflowCommonModule, ], providers: [ ApplicationResolver, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index 141b1308076..5d389fa32b2 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -3,11 +3,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { parse } from 'path'; import { isDefined } from 'twenty-shared/utils'; -import { Sources } from 'twenty-shared/types'; +import { HTTPMethod, Sources } from 'twenty-shared/types'; import { ApplicationManifest, FieldManifest, ObjectManifest, + RoleManifest, ServerlessFunctionManifest, ServerlessFunctionTriggerManifest, } from 'twenty-shared/application'; @@ -39,10 +40,10 @@ import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-f import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label-or-throw.util'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; -import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service'; import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; -import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service'; import { PermissionFlagService } from 'src/engine/metadata-modules/permission-flag/permission-flag.service'; +import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; +import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service'; @Injectable() export class ApplicationSyncService { @@ -62,7 +63,6 @@ export class ApplicationSyncService { private readonly routeTriggerV2Service: RouteTriggerV2Service, private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly roleService: RoleService, - private readonly roleTargetService: RoleTargetService, private readonly objectPermissionService: ObjectPermissionService, private readonly fieldPermissionService: FieldPermissionService, private readonly permissionService: PermissionFlagService, @@ -106,6 +106,12 @@ export class ApplicationSyncService { }); } + await this.syncRoles({ + manifest, + workspaceId, + applicationId: application.id, + }); + this.logger.log('✅ Application sync from manifest completed'); } @@ -130,17 +136,12 @@ export class ApplicationSyncService { version: packageJson.version, sourcePath: 'cli-sync', // Placeholder for CLI-synced apps serverlessFunctionLayerId: null, + defaultServerlessFunctionRoleId: null, workspaceId, })); let serverlessFunctionLayerId = application.serverlessFunctionLayerId; - await this.syncApplicationRole({ - applicationId: application.id, - manifest, - workspaceId, - }); - if (manifest.serverlessFunctions.length > 0) { if (!isDefined(serverlessFunctionLayerId)) { serverlessFunctionLayerId = ( @@ -175,114 +176,177 @@ export class ApplicationSyncService { description: manifest.application.description, version: packageJson.version, serverlessFunctionLayerId, + defaultServerlessFunctionRoleId: null, }); } - private async syncApplicationRole({ + private async syncRoles({ manifest, - applicationId, workspaceId, + applicationId, }: { manifest: ApplicationManifest; - applicationId: string; workspaceId: string; + applicationId: string; }) { - const applicationRole = manifest.application.applicationRole; + let defaultServerlessFunctionRoleId: string | null = null; - if ( - isDefined(applicationRole) && - isDefined(applicationRole.universalIdentifier) - ) { - let role = await this.roleService.getRoleByUniversalIdentifier({ - universalIdentifier: applicationRole.universalIdentifier, + for (const role of manifest.roles ?? []) { + let existingRole = await this.roleService.getRoleByUniversalIdentifier({ + universalIdentifier: role.universalIdentifier, workspaceId, }); - if (role) { + if (existingRole) { await this.roleService.updateRole({ input: { - id: role.id, - update: { - ...applicationRole, - canAccessAllTools: false, - canUpdateAllSettings: false, - canBeAssignedToAgents: false, - canBeAssignedToUsers: false, - canBeAssignedToApiKeys: false, - canBeAssignedToApplications: true, - }, + id: existingRole.id, + update: role, }, workspaceId, }); } else { - role = await this.roleService.createRole({ - input: { - canReadAllObjectRecords: true, - canUpdateAllObjectRecords: false, - canSoftDeleteAllObjectRecords: false, - canDestroyAllObjectRecords: false, - ...applicationRole, - canAccessAllTools: false, - canUpdateAllSettings: false, - canBeAssignedToAgents: false, - canBeAssignedToUsers: false, - canBeAssignedToApiKeys: false, - canBeAssignedToApplications: true, - }, - workspaceId, - applicationId: applicationId, - }); - - await this.roleTargetService.create({ - createRoleTargetInput: { - roleId: role.id, - applicationId: applicationId, - universalIdentifier: role.universalIdentifier, - targetMetadataForeignKey: 'targetApplicationId', - targetId: applicationId, - }, + existingRole = await this.roleService.createRole({ + input: role, workspaceId, + applicationId, }); } + await this.syncApplicationRolePermissions({ + role, + workspaceId, + roleId: existingRole.id, + }); + if ( - isDefined(applicationRole.objectPermissions) && - applicationRole.objectPermissions.length > 0 + existingRole.universalIdentifier === + manifest.application.functionRoleUniversalIdentifier ) { + defaultServerlessFunctionRoleId = existingRole.id; + } + } + + if (isDefined(defaultServerlessFunctionRoleId)) { + await this.applicationService.update(applicationId, { + defaultServerlessFunctionRoleId: defaultServerlessFunctionRoleId, + }); + } + } + + private async syncApplicationRolePermissions({ + role, + workspaceId, + roleId, + }: { + role: RoleManifest; + workspaceId: string; + roleId: string; + }) { + if ( + (role.objectPermissions ?? []).length > 0 || + (role.fieldPermissions ?? []).length > 0 + ) { + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const { idByNameSingular: objectIdByNameSingular } = + buildObjectIdByNameMaps(flatObjectMetadataMaps); + + const formattedObjectPermissions = role.objectPermissions + ?.map((perm) => ({ + ...perm, + objectMetadataId: isDefined(perm.objectNameSingular) + ? objectIdByNameSingular[perm.objectNameSingular] + : isDefined(perm.objectUniversalIdentifier) + ? flatObjectMetadataMaps.idByUniversalIdentifier[ + perm.objectUniversalIdentifier + ] + : undefined, + })) + .filter((perm): perm is typeof perm & { objectMetadataId: string } => + isDefined(perm.objectMetadataId), + ); + + if (isDefined(formattedObjectPermissions)) { await this.objectPermissionService.upsertObjectPermissions({ workspaceId, input: { - roleId: role.id, - objectPermissions: applicationRole.objectPermissions, + roleId, + objectPermissions: formattedObjectPermissions, }, }); } - if ( - isDefined(applicationRole.fieldPermissions) && - applicationRole.fieldPermissions.length > 0 - ) { + const formattedFieldPermissions = role?.fieldPermissions + ?.map((perm) => { + const objectMetadataId = isDefined(perm.objectNameSingular) + ? objectIdByNameSingular[perm.objectNameSingular] + : isDefined(perm.objectUniversalIdentifier) + ? flatObjectMetadataMaps.idByUniversalIdentifier[ + perm.objectUniversalIdentifier + ] + : undefined; + + const fieldMetadataId = isDefined(objectMetadataId) + ? isDefined(perm.fieldName) + ? Object.values(flatFieldMetadataMaps.byId).find( + (flatField) => + isDefined(flatField) && + flatField.objectMetadataId === objectMetadataId && + flatField.name === perm.fieldName, + )?.id + : isDefined(perm.fieldUniversalIdentifier) + ? Object.values(flatFieldMetadataMaps.byId).find( + (flatField) => + isDefined(flatField) && + flatField.objectMetadataId === objectMetadataId && + flatField.universalIdentifier === + perm.fieldUniversalIdentifier, + )?.id + : undefined + : undefined; + + return { + ...perm, + objectMetadataId, + fieldMetadataId, + }; + }) + .filter( + ( + perm, + ): perm is typeof perm & { + objectMetadataId: string; + fieldMetadataId: string; + } => + isDefined(perm.objectMetadataId) && isDefined(perm.fieldMetadataId), + ); + + if (isDefined(formattedFieldPermissions)) { await this.fieldPermissionService.upsertFieldPermissions({ workspaceId, input: { - roleId: role.id, - fieldPermissions: applicationRole.fieldPermissions, + roleId, + fieldPermissions: formattedFieldPermissions, }, }); } + } - if ( - isDefined(applicationRole.permissionFlags) && - applicationRole.permissionFlags.length > 0 - ) { - await this.permissionService.upsertPermissionFlags({ - workspaceId, - input: { - roleId: role.id, - permissionFlagKeys: applicationRole.permissionFlags, - }, - }); - } + if (isDefined(role?.permissionFlags) && role.permissionFlags.length > 0) { + await this.permissionService.upsertPermissionFlags({ + workspaceId, + input: { + roleId, + permissionFlagKeys: role.permissionFlags, + }, + }); } } @@ -1036,7 +1100,7 @@ export class ApplicationSyncService { id: triggerToUpdate.id, update: { path: triggerToSync.path, - httpMethod: triggerToSync.httpMethod, + httpMethod: triggerToSync.httpMethod as HTTPMethod, isAuthRequired: triggerToSync.isAuthRequired, }, }; @@ -1054,7 +1118,7 @@ export class ApplicationSyncService { const createRouteTriggerInput = { path: triggerToCreate.path, - httpMethod: triggerToCreate.httpMethod, + httpMethod: triggerToCreate.httpMethod as HTTPMethod, isAuthRequired: triggerToCreate.isAuthRequired, serverlessFunctionId, }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts index 4bd998eedc1..6026068fe26 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts @@ -1,4 +1,4 @@ -import { ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType } from '@nestjs/graphql'; import { Column, @@ -14,6 +14,7 @@ import { UpdateDateColumn, } from 'typeorm'; +import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; @@ -60,6 +61,12 @@ export class ApplicationEntity { @Column({ nullable: true, type: 'uuid' }) serverlessFunctionLayerId: string | null; + @Column({ nullable: true, type: 'uuid' }) + defaultServerlessFunctionRoleId: string | null; + + @Field(() => RoleDTO, { nullable: true }) + defaultServerlessFunctionRole: RoleDTO | null; + @Column({ nullable: false, type: 'boolean', default: true }) canBeUninstalled: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts index dc8aead2fc2..a0c966445e5 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts @@ -13,6 +13,7 @@ import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicatio import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto'; import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; +import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; @ObjectType('Application') export class ApplicationDTO { @@ -43,6 +44,15 @@ export class ApplicationDTO { @IsBoolean() canBeUninstalled: boolean; + @IsOptional() + @IsString() + @Field({ nullable: true }) + defaultServerlessFunctionRoleId?: string; + + @IsOptional() + @Field(() => RoleDTO, { nullable: true }) + defaultServerlessFunctionRole?: RoleDTO; + @Field(() => [AgentDTO]) agents?: AgentDTO[]; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts index 0c3847947fb..9280f6e36a5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts @@ -13,6 +13,7 @@ export const AuthExceptionCode = appendCommonExceptionCode({ EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED', CLIENT_NOT_FOUND: 'CLIENT_NOT_FOUND', WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND', + APPLICATION_NOT_FOUND: 'APPLICATION_NOT_FOUND', INVALID_INPUT: 'INVALID_INPUT', FORBIDDEN_EXCEPTION: 'FORBIDDEN_EXCEPTION', INSUFFICIENT_SCOPES: 'INSUFFICIENT_SCOPES', diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index df475557ed7..2bcc9498f8f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -63,6 +63,7 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service'; import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { TwoFactorAuthenticationMethodEntity } from '../two-factor-authentication/entities/two-factor-authentication-method.entity'; import { TwoFactorAuthenticationModule } from '../two-factor-authentication/two-factor-authentication.module'; @@ -87,6 +88,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; UserEntity, AppTokenEntity, ApiKeyEntity, + ApplicationEntity, FeatureFlagEntity, WorkspaceSSOIdentityProviderEntity, KeyValuePairEntity, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index 1dc74e48c6c..ce8732a2a77 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -4,7 +4,10 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { type JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { + type JwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { JwtAuthStrategy } from './jwt.auth.strategy'; @@ -24,6 +27,7 @@ describe('JwtAuthStrategy', () => { let userWorkspaceRepository: any; let userRepository: any; let apiKeyRepository: any; + let applicationRepository: any; let jwtWrapperService: any; let permissionsService: any; @@ -49,6 +53,10 @@ describe('JwtAuthStrategy', () => { findOne: jest.fn(), }; + applicationRepository = { + findOne: jest.fn(), + }; + jwtWrapperService = { extractJwtFromRequest: jest.fn(() => () => 'token'), }; @@ -66,7 +74,7 @@ describe('JwtAuthStrategy', () => { it('should throw AuthException if type is API_KEY and workspace is not found', async () => { const payload = { ...jwt, - type: 'API_KEY', + type: JwtTokenTypeEnum.API_KEY, }; workspaceRepository.findOneBy.mockResolvedValue(null); @@ -74,6 +82,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -91,7 +100,7 @@ describe('JwtAuthStrategy', () => { it('should throw AuthExceptionCode if type is API_KEY not found', async () => { const payload = { ...jwt, - type: 'API_KEY', + type: JwtTokenTypeEnum.API_KEY, }; const mockWorkspace = new WorkspaceEntity(); @@ -104,6 +113,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -121,7 +131,7 @@ describe('JwtAuthStrategy', () => { it('should throw AuthExceptionCode if API_KEY is revoked', async () => { const payload = { ...jwt, - type: 'API_KEY', + type: JwtTokenTypeEnum.API_KEY, }; const mockWorkspace = new WorkspaceEntity(); @@ -137,6 +147,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -154,7 +165,7 @@ describe('JwtAuthStrategy', () => { it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => { const payload = { ...jwt, - type: 'API_KEY', + type: JwtTokenTypeEnum.API_KEY, }; const mockWorkspace = new WorkspaceEntity(); @@ -170,6 +181,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -198,7 +210,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, }; @@ -210,6 +222,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -234,7 +247,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, }; @@ -248,6 +261,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -272,7 +286,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, }; @@ -290,6 +304,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -303,6 +318,44 @@ describe('JwtAuthStrategy', () => { }); }); + describe('APPLICATION token validation', () => { + it('should throw AuthExceptionCode if type is APPLICATION, and application not found', async () => { + const validApplicationId = randomUUID(); + const validWorkspaceId = randomUUID(); + + const payload = { + sub: validApplicationId, + type: JwtTokenTypeEnum.APPLICATION, + applicationId: validApplicationId, + workspaceId: validWorkspaceId, + }; + + workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity()); + + applicationRepository.findOne.mockResolvedValue(null); + + strategy = new JwtAuthStrategy( + jwtWrapperService, + workspaceRepository, + applicationRepository, + userRepository, + userWorkspaceRepository, + apiKeyRepository, + permissionsService, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException('Application not found', expect.any(String)), + ); + + try { + await strategy.validate(payload as JwtPayload); + } catch (e) { + expect(e.code).toBe(AuthExceptionCode.APPLICATION_NOT_FOUND); + } + }); + }); + describe('Impersonation validation', () => { it('should throw AuthException if impersonation token has missing impersonatorUserWorkspaceId', async () => { const validUserId = randomUUID(); @@ -311,7 +364,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -335,6 +388,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -357,7 +411,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -380,6 +434,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -401,7 +456,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -427,6 +482,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -449,7 +505,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -485,6 +541,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -507,7 +564,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -537,6 +594,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -560,7 +618,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -607,6 +665,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -629,7 +688,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -676,6 +735,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -699,7 +759,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -746,6 +806,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -768,7 +829,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -809,6 +870,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, @@ -837,7 +899,7 @@ describe('JwtAuthStrategy', () => { const payload = { sub: validUserId, - type: 'ACCESS', + type: JwtTokenTypeEnum.ACCESS, userWorkspaceId: validUserWorkspaceId, workspaceId: validWorkspaceId, isImpersonating: true, @@ -874,6 +936,7 @@ describe('JwtAuthStrategy', () => { strategy = new JwtAuthStrategy( jwtWrapperService, workspaceRepository, + applicationRepository, userRepository, userWorkspaceRepository, apiKeyRepository, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index 7718e6b093e..b2196489bb0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -16,9 +16,11 @@ import { import { type AccessTokenJwtPayload, type ApiKeyTokenJwtPayload, + ApplicationTokenJwtPayload, type AuthContext, type FileTokenJwtPayload, type JwtPayload, + JwtTokenTypeEnum, type WorkspaceAgnosticTokenJwtPayload, } from 'src/engine/core-modules/auth/types/auth-context.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; @@ -27,12 +29,16 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; + @Injectable() export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { constructor( private readonly jwtWrapperService: JwtWrapperService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, @InjectRepository(UserEntity) private readonly userRepository: Repository, @InjectRepository(UserWorkspaceEntity) @@ -52,7 +58,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { >(rawJwtToken); const appSecretBody = - decodedToken.type === 'WORKSPACE_AGNOSTIC' + decodedToken.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC ? decodedToken.userId : decodedToken.workspaceId; @@ -270,7 +276,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { private async validateWorkspaceAgnosticToken( payload: WorkspaceAgnosticTokenJwtPayload, - ) { + ): Promise { const user = await this.userRepository.findOne({ where: { id: payload.sub }, }); @@ -283,6 +289,39 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { return { user, authProvider: payload.authProvider }; } + private async validateApplicationToken( + payload: ApplicationTokenJwtPayload, + ): Promise { + const workspace = await this.workspaceRepository.findOneBy({ + id: payload.workspaceId, + }); + + if (!isDefined(workspace)) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + + const applicationId = payload.sub ?? payload.applicationId; + + const application = await this.applicationRepository.findOne({ + where: { id: applicationId }, + }); + + if (!isDefined(application)) { + throw new AuthException( + 'Application not found', + AuthExceptionCode.APPLICATION_NOT_FOUND, + ); + } + + return { + application, + workspace, + }; + } + private isLegacyApiKeyPayload( payload: JwtPayload, ): payload is ApiKeyTokenJwtPayload { @@ -291,19 +330,25 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { async validate(payload: JwtPayload): Promise { // Support legacy api keys - if (payload.type === 'API_KEY' || this.isLegacyApiKeyPayload(payload)) { + if ( + payload.type === JwtTokenTypeEnum.API_KEY || + this.isLegacyApiKeyPayload(payload) + ) { return await this.validateAPIKey(payload); } - if (payload.type === 'WORKSPACE_AGNOSTIC') { + if (payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC) { return await this.validateWorkspaceAgnosticToken(payload); } - // `!payload.type` is here to support legacy token - if (payload.type === 'ACCESS' || !payload.type) { + if (payload.type === JwtTokenTypeEnum.ACCESS) { return await this.validateAccessToken(payload); } + if (payload.type === JwtTokenTypeEnum.APPLICATION) { + return await this.validateApplicationToken(payload); + } + throw new AuthException( 'Invalid token', AuthExceptionCode.INVALID_JWT_TOKEN_TYPE, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index 5126094091d..08426f27967 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -265,10 +265,7 @@ describe('AccessTokenService', () => { const result = await service.validateTokenByRequest(mockRequest); expect(result).toEqual(mockAuthContext); - expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( - mockToken, - 'ACCESS', - ); + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken); expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken); expect(service['jwtStrategy'].validate).toHaveBeenCalledWith( mockDecodedToken, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index 374a736b5ca..0314994aa64 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -154,7 +154,7 @@ export class AccessTokenService { } async validateToken(token: string): Promise { - await this.jwtWrapperService.verifyJwtToken(token, JwtTokenTypeEnum.ACCESS); + await this.jwtWrapperService.verifyJwtToken(token); const decoded = this.jwtWrapperService.decode(token); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts new file mode 100644 index 00000000000..99ec4a48652 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.spec.ts @@ -0,0 +1,159 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationException } from 'src/engine/core-modules/application/application.exception'; +import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception'; + +describe('ApplicationTokenService', () => { + let service: ApplicationTokenService; + let jwtWrapperService: JwtWrapperService; + let workspaceRepository: Repository; + let applicationRepository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApplicationTokenService, + { + provide: JwtWrapperService, + useValue: { + sign: jest.fn(), + verifyJwtToken: jest.fn(), + decode: jest.fn(), + generateAppSecret: jest.fn(), + extractJwtFromRequest: jest.fn(), + }, + }, + { + provide: getRepositoryToken(ApplicationEntity), + useClass: Repository, + }, + { + provide: getRepositoryToken(WorkspaceEntity), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(ApplicationTokenService); + jwtWrapperService = module.get(JwtWrapperService); + applicationRepository = module.get>( + getRepositoryToken(ApplicationEntity), + ); + workspaceRepository = module.get>( + getRepositoryToken(WorkspaceEntity), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateApplicationToken', () => { + it('should generate an application token successfully', async () => { + const workspaceId = 'workspace-id'; + const applicationId = 'application-id'; + const mockWorkspace = { id: workspaceId }; + const mockApplication = { id: applicationId }; + const mockToken = 'mock-token'; + + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as WorkspaceEntity); + jest + .spyOn(applicationRepository, 'findOne') + .mockResolvedValue(mockApplication as ApplicationEntity); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateApplicationToken({ + workspaceId, + applicationId, + expiresInSeconds: 10, + }); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: applicationId, + applicationId, + }), + expect.any(Object), + ); + }); + + it('should handle missing userId successfully', async () => { + const workspaceId = 'workspace-id'; + const applicationId = 'application-id'; + const mockWorkspace = { id: workspaceId }; + const mockApplication = { id: applicationId }; + const mockToken = 'mock-token'; + + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as WorkspaceEntity); + jest + .spyOn(applicationRepository, 'findOne') + .mockResolvedValue(mockApplication as ApplicationEntity); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateApplicationToken({ + workspaceId, + applicationId, + expiresInSeconds: 10, + }); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: applicationId, + applicationId, + workspaceId: workspaceId, + }), + expect.any(Object), + ); + }); + }); + + it('should throw an error if application is not found', async () => { + const workspaceId = 'workspace-id'; + + const mockWorkspace = { id: workspaceId }; + + jest.spyOn(applicationRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as WorkspaceEntity); + + await expect( + service.generateApplicationToken({ + applicationId: 'non-existent-application', + workspaceId: 'workspace-id', + expiresInSeconds: 10, + }), + ).rejects.toThrow(ApplicationException); + }); + + it('should throw an error if workspace is not found', async () => { + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.generateApplicationToken({ + applicationId: 'application-id', + workspaceId: 'non-existent-workspace', + expiresInSeconds: 10, + }), + ).rejects.toThrow(WorkspaceException); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts new file mode 100644 index 00000000000..58dc8096ef2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/application-token.service.ts @@ -0,0 +1,80 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Injectable } from '@nestjs/common'; + +import { Repository } from 'typeorm'; +import { addMilliseconds } from 'date-fns'; +import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; +import ms from 'ms'; + +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + ApplicationTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; +import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { + ApplicationException, + ApplicationExceptionCode, +} from 'src/engine/core-modules/application/application.exception'; + +@Injectable() +export class ApplicationTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + ) {} + + async generateApplicationToken({ + workspaceId, + applicationId, + expiresInSeconds, + }: Omit & { + expiresInSeconds: number; + }): Promise { + const expiresIn = `${expiresInSeconds}s`; + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const workspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + }); + + assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); + + const application = await this.applicationRepository.findOne({ + where: { id: applicationId, workspaceId }, + }); + + assertIsDefinedOrThrow( + application, + new ApplicationException( + 'Application not found', + ApplicationExceptionCode.APPLICATION_NOT_FOUND, + ), + ); + + const jwtPayload: ApplicationTokenJwtPayload = { + sub: applicationId, + applicationId, + workspaceId, + type: JwtTokenTypeEnum.APPLICATION, + }; + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret: this.jwtWrapperService.generateAppSecret( + JwtTokenTypeEnum.APPLICATION, + workspaceId, + ), + expiresIn, + }), + expiresAt, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts index bb3dda1a8b5..b25c5d259e6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts @@ -3,6 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; import { LoginTokenService } from './login-token.service'; @@ -67,7 +68,7 @@ describe('LoginTokenService', () => { expiresAt: expect.any(Date), }); expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith( - 'LOGIN', + JwtTokenTypeEnum.LOGIN, workspaceId, ); expect(twentyConfigService.get).toHaveBeenCalledWith( @@ -77,7 +78,7 @@ describe('LoginTokenService', () => { { sub: email, workspaceId, - type: 'LOGIN', + type: JwtTokenTypeEnum.LOGIN, authProvider: AuthProviderEnum.Password, impersonatorUserId: undefined, }, @@ -112,14 +113,14 @@ describe('LoginTokenService', () => { expiresAt: expect.any(Date), }); expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith( - 'LOGIN', + JwtTokenTypeEnum.LOGIN, workspaceId, ); expect(jwtWrapperService.sign).toHaveBeenCalledWith( { sub: email, workspaceId, - type: 'LOGIN', + type: JwtTokenTypeEnum.LOGIN, authProvider: AuthProviderEnum.Impersonation, impersonatorUserWorkspaceId, }, @@ -143,10 +144,7 @@ describe('LoginTokenService', () => { const result = await service.verifyLoginToken(mockToken); expect(result).toEqual({ sub: mockEmail }); - expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( - mockToken, - 'LOGIN', - ); + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken); expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, { json: true, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts index 92a9434875c..1efc6d90aee 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -52,10 +52,7 @@ export class LoginTokenService { } async verifyLoginToken(loginToken: string): Promise { - await this.jwtWrapperService.verifyJwtToken( - loginToken, - JwtTokenTypeEnum.LOGIN, - ); + await this.jwtWrapperService.verifyJwtToken(loginToken); return this.jwtWrapperService.decode(loginToken, { json: true, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts index 746ce0a9c71..9a56f1599f4 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -100,10 +100,7 @@ describe('RefreshTokenService', () => { const result = await service.verifyRefreshToken(mockToken); expect(result).toEqual({ user: mockUser, token: mockAppToken }); - expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( - mockToken, - 'REFRESH', - ); + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken); }); it('should throw an error if the token is malformed', async () => { @@ -154,9 +151,9 @@ describe('RefreshTokenService', () => { { sub: userId, workspaceId, - type: 'REFRESH', + type: JwtTokenTypeEnum.REFRESH, userId: 'user-id', - targetedTokenType: 'ACCESS', + targetedTokenType: JwtTokenTypeEnum.ACCESS, }, expect.objectContaining({ secret: 'mock-secret', @@ -190,8 +187,8 @@ describe('RefreshTokenService', () => { (jwtWrapperService.decode as jest.Mock).mockReturnValue({ sub: userId, jti: tokenId, - type: 'REFRESH', - targetedTokenType: 'ACCESS', + type: JwtTokenTypeEnum.REFRESH, + targetedTokenType: JwtTokenTypeEnum.ACCESS, isImpersonating: true, impersonatorUserWorkspaceId: 'uw-imp', impersonatedUserWorkspaceId: 'uw-orig', diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts index ae25a667248..5d6bf21fa2f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -36,10 +36,7 @@ export class RefreshTokenService { async verifyRefreshToken(refreshToken: string) { const coolDown = this.twentyConfigService.get('REFRESH_TOKEN_COOL_DOWN'); - await this.jwtWrapperService.verifyJwtToken( - refreshToken, - JwtTokenTypeEnum.REFRESH, - ); + await this.jwtWrapperService.verifyJwtToken(refreshToken); const jwtPayload = this.jwtWrapperService.decode(refreshToken); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts index 5b67d095fe9..e387df77e29 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts @@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; import { TransientTokenService } from './transient-token.service'; @@ -72,7 +73,7 @@ describe('TransientTokenService', () => { expect(jwtWrapperService.sign).toHaveBeenCalledWith( { sub: workspaceMemberId, - type: 'LOGIN', + type: JwtTokenTypeEnum.LOGIN, userId, workspaceId, workspaceMemberId, @@ -108,10 +109,7 @@ describe('TransientTokenService', () => { userId: mockPayload.userId, workspaceId: mockPayload.workspaceId, }); - expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( - mockToken, - 'LOGIN', - ); + expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken); expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts index 46228558c30..5f379ee7bc7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts @@ -53,10 +53,7 @@ export class TransientTokenService { async verifyTransientToken( transientToken: string, ): Promise> { - await this.jwtWrapperService.verifyJwtToken( - transientToken, - JwtTokenTypeEnum.LOGIN, - ); + await this.jwtWrapperService.verifyJwtToken(transientToken); const { type: _type, ...payload } = this.jwtWrapperService.decode(transientToken); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts index 711fa25c0e1..de96d2e6622 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts @@ -9,6 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type'; describe('WorkspaceAgnosticToken', () => { let service: WorkspaceAgnosticTokenService; @@ -95,7 +96,7 @@ describe('WorkspaceAgnosticToken', () => { authProvider: AuthProviderEnum.Password, sub: userId, userId: userId, - type: 'WORKSPACE_AGNOSTIC', + type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, }, expect.objectContaining({ secret: 'mocked-secret', @@ -132,7 +133,7 @@ describe('WorkspaceAgnosticToken', () => { const mockPayload = { sub: userId, userId: userId, - type: 'WORKSPACE_AGNOSTIC', + type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, }; const mockUser = { id: userId }; @@ -177,7 +178,7 @@ describe('WorkspaceAgnosticToken', () => { const mockPayload = { sub: userId, userId: userId, - type: 'WORKSPACE_AGNOSTIC', + type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, }; jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 42d9ecb80d1..5a8d5f3ad74 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -16,6 +16,8 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; @Module({ imports: [ @@ -26,6 +28,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi WorkspaceEntity, UserWorkspaceEntity, ApiKeyEntity, + ApplicationEntity, ]), TypeORMModule, DataSourceModule, @@ -38,6 +41,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi LoginTokenService, RefreshTokenService, WorkspaceAgnosticTokenService, + ApplicationTokenService, ], exports: [ RenewTokenService, @@ -45,6 +49,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi LoginTokenService, RefreshTokenService, WorkspaceAgnosticTokenService, + ApplicationTokenService, ], }) export class TokenModule {} diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index af4b0b56e3c..3b1e72891c7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -3,12 +3,14 @@ import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; export type AuthContext = { user?: UserEntity | null | undefined; apiKey?: ApiKeyEntity | null | undefined; workspaceMemberId?: string; workspace?: WorkspaceEntity; + application?: ApplicationEntity | null | undefined; userWorkspaceId?: string; userWorkspace?: UserWorkspaceEntity; authProvider?: AuthProviderEnum; @@ -28,6 +30,7 @@ export enum JwtTokenTypeEnum { POSTGRES_PROXY = 'POSTGRES_PROXY', REMOTE_SERVER = 'REMOTE_SERVER', KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY', + APPLICATION = 'APPLICATION', } type CommonPropertiesJwtPayload = { @@ -83,6 +86,12 @@ export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & { jti?: string; }; +export type ApplicationTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.APPLICATION; + workspaceId: string; + applicationId: string; +}; + export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & { type: JwtTokenTypeEnum.ACCESS; workspaceId: string; @@ -106,6 +115,7 @@ export type RemoteServerTokenJwtPayload = CommonPropertiesJwtPayload & { export type JwtPayload = | AccessTokenJwtPayload | ApiKeyTokenJwtPayload + | ApplicationTokenJwtPayload | WorkspaceAgnosticTokenJwtPayload | LoginTokenJwtPayload | TransientTokenJwtPayload diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts index 72cced00bc7..1fcead36d2a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts @@ -52,6 +52,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => { }); case AuthExceptionCode.USER_NOT_FOUND: case AuthExceptionCode.WORKSPACE_NOT_FOUND: + case AuthExceptionCode.APPLICATION_NOT_FOUND: case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND: throw new AuthenticationError(exception); case AuthExceptionCode.INTERNAL_SERVER_ERROR: diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts index b74ed7c5fce..ff9459e6166 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts @@ -30,6 +30,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => { case AuthExceptionCode.UNAUTHENTICATED: case AuthExceptionCode.USER_NOT_FOUND: case AuthExceptionCode.WORKSPACE_NOT_FOUND: + case AuthExceptionCode.APPLICATION_NOT_FOUND: return 401; case AuthExceptionCode.INTERNAL_SERVER_ERROR: case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND: diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index 8b25a970746..1ba260fd3a6 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -4,10 +4,7 @@ import { Injectable, } from '@nestjs/common'; -import { - type FileTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; @@ -28,7 +25,6 @@ export class FilePathGuard implements CanActivate { try { const payload = await this.jwtWrapperService.verifyJwtToken( fileSignature, - JwtTokenTypeEnum.FILE, ignoreExpirationToken ? { ignoreExpiration: true } : {}, ); diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index a7493654b35..8ec925416b8 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -17,13 +17,8 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { - type AccessTokenJwtPayload, - type FileTokenJwtPayload, type JwtPayload, JwtTokenTypeEnum, - type RefreshTokenJwtPayload, - type TransientTokenJwtPayload, - type WorkspaceAgnosticTokenJwtPayload, } from 'src/engine/core-modules/auth/types/auth-context.type'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -54,16 +49,10 @@ export class JwtWrapperService { verifyJwtToken( token: string, - type: JwtTokenTypeEnum, options?: JwtVerifyOptions, + isLegacyApiKey = false, ) { - const payload = this.decode< - | TransientTokenJwtPayload - | RefreshTokenJwtPayload - | WorkspaceAgnosticTokenJwtPayload - | AccessTokenJwtPayload - | FileTokenJwtPayload - >(token, { + const payload = this.decode(token, { json: true, }); @@ -71,37 +60,56 @@ export class JwtWrapperService { throw new AuthException('No payload', AuthExceptionCode.UNAUTHENTICATED); } - // @TODO: Migrate to use type from payload instead of parameter - type = - payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC - ? JwtTokenTypeEnum.WORKSPACE_AGNOSTIC - : type; + const type = payload.type; - // TODO: check if this is really needed - if (type !== 'FILE' && !payload.sub) { + const appSecretBody = + 'workspaceId' in payload + ? payload.workspaceId + : 'userId' in payload + ? payload.userId + : undefined; + + if (!isDefined(appSecretBody)) { throw new AuthException( - 'No payload sub', - AuthExceptionCode.UNAUTHENTICATED, + 'Invalid token type', + AuthExceptionCode.INVALID_JWT_TOKEN_TYPE, ); } try { - // TODO: Deprecate this once old API KEY tokens are no longer in use - if (!payload.type && !('workspaceId' in payload) && type === 'ACCESS') { + // Supporting old API KEY tokens + if ( + !payload.type && + !('workspaceId' in payload) && + type === JwtTokenTypeEnum.API_KEY + ) { return this.jwtService.verify(token, { ...options, secret: this.generateAppSecretLegacy(), }); } - const appSecretBody = - 'workspaceId' in payload ? payload.workspaceId : payload.userId; - - if (!isDefined(appSecretBody)) { - throw new AuthException( - 'Invalid token type', - AuthExceptionCode.INVALID_JWT_TOKEN_TYPE, - ); + // This is due to an unfortunate mistake in the secret generation of API_KEY + // tokens. We used to sign with ACCESS Jwt Token Type instead of API_KEY. + // Now we need to check both cases not to break the existing api keys + // See this PR for context -> https://github.com/twentyhq/twenty/pull/16504 + // This code block can be deleted, but all api keys created before + // 12/12/2025 will be broken + if (type === JwtTokenTypeEnum.API_KEY && !isLegacyApiKey) { + try { + return this.jwtService.verify(token, { + ...options, + secret: this.generateAppSecret(type, appSecretBody), + }); + } catch { + return this.jwtService.verify(token, { + ...options, + secret: this.generateAppSecret( + JwtTokenTypeEnum.ACCESS, + appSecretBody, + ), + }); + } } return this.jwtService.verify(token, { @@ -114,17 +122,17 @@ export class JwtWrapperService { 'Token has expired.', AuthExceptionCode.UNAUTHENTICATED, ); - } else if (error instanceof jwt.JsonWebTokenError) { + } + if (error instanceof jwt.JsonWebTokenError) { throw new AuthException( 'Token invalid.', AuthExceptionCode.UNAUTHENTICATED, ); - } else { - throw new AuthException( - 'Unknown token error.', - AuthExceptionCode.INVALID_INPUT, - ); } + throw new AuthException( + 'Unknown token error.', + AuthExceptionCode.INVALID_INPUT, + ); } } diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index edf06aefe37..6721ce36ee1 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -99,10 +99,10 @@ export class OpenApiService { } async generateCoreSchema(request: Request): Promise { - const baseUrl = getServerUrl( - this.twentyConfigService.get('SERVER_URL'), - `${request.protocol}://${request.get('host')}`, - ); + const baseUrl = getServerUrl({ + serverUrlEnv: this.twentyConfigService.get('SERVER_URL'), + serverUrlFallback: `${request.protocol}://${request.get('host')}`, + }); const tokenFromQuery = request.query.token; const schema = baseSchema( @@ -270,10 +270,10 @@ export class OpenApiService { async generateMetaDataSchema( request: Request, ): Promise { - const baseUrl = getServerUrl( - this.twentyConfigService.get('SERVER_URL'), - `${request.protocol}://${request.get('host')}`, - ); + const baseUrl = getServerUrl({ + serverUrlEnv: this.twentyConfigService.get('SERVER_URL'), + serverUrlFallback: `${request.protocol}://${request.get('host')}`, + }); const tokenFromQuery = request.query.token; const schema = baseSchema( diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface.ts index 38e55698f05..9fe554b4505 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface.ts @@ -18,9 +18,15 @@ export type ServerlessExecuteResult = { // TODO refactor to be using FlatServerlessFunction export interface ServerlessDriver { delete(serverlessFunction: ServerlessFunctionEntity): Promise; - execute( - serverlessFunction: ServerlessFunctionEntity, - payload: object, - version: string, - ): Promise; + execute({ + serverlessFunction, + payload, + version, + env, + }: { + serverlessFunction: ServerlessFunctionEntity; + payload: object; + version: string; + env?: Record; + }): Promise; } diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts index cccbf0fad34..15e803f3234 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts @@ -27,7 +27,6 @@ import { } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface'; import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; -import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var'; import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory'; import { copyAndBuildDependencies } from 'src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies'; import { copyExecutor } from 'src/engine/core-modules/serverless/drivers/utils/copy-executor'; @@ -305,11 +304,17 @@ export class LambdaDriver implements ServerlessDriver { .trim(); } - async execute( - serverlessFunction: ServerlessFunctionEntity, - payload: object, - version: string, - ): Promise { + async execute({ + serverlessFunction, + payload, + version, + env, + }: { + serverlessFunction: ServerlessFunctionEntity; + payload: object; + version: string; + env?: Record; + }): Promise { await this.build(serverlessFunction); await this.waitFunctionUpdates(serverlessFunction); @@ -348,7 +353,7 @@ export class LambdaDriver implements ServerlessDriver { const executorPayload: LambdaDriverExecutorPayload = { params: payload, code: compiledCode, - env: buildEnvVar(serverlessFunction), + env: env ?? {}, handlerName: serverlessFunction.handlerName, }; diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts index b9d1c2eeece..da0e03cc1f3 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts @@ -9,7 +9,6 @@ import { import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { SERVERLESS_TMPDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/serverless-tmpdir-folder'; -import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var'; import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory'; import { copyAndBuildDependencies } from 'src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies'; import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error'; @@ -61,11 +60,17 @@ export class LocalDriver implements ServerlessDriver { await this.createLayerIfNotExists(serverlessFunction); } - async execute( - serverlessFunction: ServerlessFunctionEntity, - payload: object, - version: string, - ): Promise { + async execute({ + serverlessFunction, + payload, + version, + env, + }: { + serverlessFunction: ServerlessFunctionEntity; + payload: object; + version: string; + env?: Record; + }): Promise { await this.build(serverlessFunction); const startTime = Date.now(); @@ -154,7 +159,7 @@ export class LocalDriver implements ServerlessDriver { const { ok, result, error, stack, stdout, stderr } = await this.runChildWithEnv({ runnerPath, - env: buildEnvVar(serverlessFunction), + env: env ?? {}, payload, timeoutMs: 900_000, // timeout is handled by the serverless function service }); diff --git a/packages/twenty-server/src/engine/core-modules/serverless/serverless.service.ts b/packages/twenty-server/src/engine/core-modules/serverless/serverless.service.ts index 4cdd8fae0d4..a586c6bc3bc 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/serverless.service.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/serverless.service.ts @@ -16,11 +16,17 @@ export class ServerlessService implements ServerlessDriver { return this.driver.delete(serverlessFunction); } - async execute( - serverlessFunction: ServerlessFunctionEntity, - payload: object, - version: string, - ): Promise { - return this.driver.execute(serverlessFunction, payload, version); + async execute({ + serverlessFunction, + payload, + version, + env, + }: { + serverlessFunction: ServerlessFunctionEntity; + payload: object; + version: string; + env?: Record; + }): Promise { + return this.driver.execute({ serverlessFunction, payload, version, env }); } } diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index 4eca22c0c7d..a565f4b9a7c 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -9,6 +9,7 @@ import { isDefined } from 'twenty-shared/utils'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { bindDataToRequestObject } from 'src/engine/utils/bind-data-to-request-object.util'; @Injectable() export class JwtAuthGuard implements CanActivate { @@ -39,13 +40,7 @@ export class JwtAuthGuard implements CanActivate { return false; } - request.user = data.user; - request.apiKey = data.apiKey; - request.workspace = data.workspace; - request.workspaceId = data.workspace?.id; - request.workspaceMetadataVersion = metadataVersion; - request.workspaceMemberId = data.workspaceMemberId; - request.userWorkspaceId = data.userWorkspaceId; + bindDataToRequestObject(data, request, metadataVersion); return true; } catch (error) { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util.ts index e9e0465105b..f91dbf9061b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util.ts @@ -73,7 +73,6 @@ export const fromCreateAgentInputToFlatAgent = ({ userWorkspaceId: null, agentId, apiKeyId: null, - targetApplicationId: null, createdAt, updatedAt: createdAt, universalIdentifier: v4(), diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts index e88954a0e0a..93c0b625b54 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts @@ -67,7 +67,6 @@ const computeAgentFlatRoleTargetToUpdate = ({ userWorkspaceId: null, agentId: flatAgent.id, apiKeyId: null, - targetApplicationId: null, createdAt: updatedAt, updatedAt, universalIdentifier: v4(), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-role-target/constants/role-target-foreign-key-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-role-target/constants/role-target-foreign-key-properties.constant.ts index 3477fa4865a..744a18d8903 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-role-target/constants/role-target-foreign-key-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-role-target/constants/role-target-foreign-key-properties.constant.ts @@ -4,5 +4,4 @@ export const ROLE_TARGET_FOREIGN_KEY_PROPERTIES = [ 'userWorkspaceId', 'apiKeyId', 'agentId', - 'targetApplicationId', ] as const satisfies (keyof FlatRoleTarget)[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-role-target/utils/from-role-target-entity-to-flat-role-target.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-role-target/utils/from-role-target-entity-to-flat-role-target.util.ts index dd41be57014..0252778638d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-role-target/utils/from-role-target-entity-to-flat-role-target.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-role-target/utils/from-role-target-entity-to-flat-role-target.util.ts @@ -11,7 +11,6 @@ export const fromRoleTargetsEntityToFlatRoleTarget = ( userWorkspaceId: roleTarget.userWorkspaceId, agentId: roleTarget.agentId, apiKeyId: roleTarget.apiKeyId, - targetApplicationId: roleTarget.targetApplicationId, applicationId: roleTarget.applicationId, universalIdentifier: roleTarget.universalIdentifier ?? roleTarget.id, createdAt: roleTarget.createdAt.toISOString(), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-create-role-input-to-flat-role-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-create-role-input-to-flat-role-to-create.util.ts index 71700e3a091..573b32033af 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-create-role-input-to-flat-role-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-create-role-input-to-flat-role-to-create.util.ts @@ -50,7 +50,7 @@ export const fromCreateRoleInputToFlatRoleToCreate = ({ workspaceId, createdAt: now, updatedAt: now, - universalIdentifier: id, + universalIdentifier: createRoleInput.universalIdentifier ?? id, applicationId, roleTargetIds: [], objectPermissionIds: [], diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts index a2b23995bb3..abd29783990 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts @@ -56,9 +56,6 @@ export class RoleTargetEntity extends SyncableEntity { @Column({ nullable: true, type: 'uuid' }) apiKeyId: string | null; - @Column({ nullable: true, type: 'uuid' }) - targetApplicationId: string | null; - @ManyToOne(() => ApiKeyEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'apiKeyId' }) apiKey: Relation; diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/utils/from-create-role-target-input-to-flat-role-target-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/utils/from-create-role-target-input-to-flat-role-target-to-create.util.ts index 74862bb2a2b..f3f074e38b8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-target/utils/from-create-role-target-input-to-flat-role-target-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/utils/from-create-role-target-input-to-flat-role-target-to-create.util.ts @@ -27,7 +27,6 @@ export const fromCreateRoleTargetInputToFlatRoleTargetToCreate = ({ userWorkspaceId: null, agentId: null, apiKeyId: null, - targetApplicationId: null, createdAt: now.toISOString(), updatedAt: now.toISOString(), universalIdentifier: universalIdentifier ?? v4(), diff --git a/packages/twenty-server/src/engine/metadata-modules/role/dtos/create-role-input.dto.ts b/packages/twenty-server/src/engine/metadata-modules/role/dtos/create-role-input.dto.ts index 5b2621e3fa9..ae0eaf48387 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/dtos/create-role-input.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/dtos/create-role-input.dto.ts @@ -1,4 +1,4 @@ -import { Field, InputType } from '@nestjs/graphql'; +import { Field, HideField, InputType } from '@nestjs/graphql'; import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator'; @@ -9,6 +9,9 @@ export class CreateRoleInput { @Field({ nullable: true }) id?: string; + @HideField() + universalIdentifier?: string; + @IsString() @Field({ nullable: false }) label: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.service.ts b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.service.ts index de434841c3c..72e22f59cad 100644 --- a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.service.ts @@ -88,22 +88,24 @@ export class RouteTriggerService { request: Request; workspaceId: string; }) { - const { workspace } = + const authContext = await this.accessTokenService.validateTokenByRequest(request); - if (!isDefined(workspace)) { + if (!isDefined(authContext.workspace)) { throw new RouteTriggerException( 'Workspace not found', RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND, ); } - if (workspace.id !== workspaceId) { + if (authContext.workspace.id !== workspaceId) { throw new RouteTriggerException( 'You are not authorized', RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION, ); } + + return authContext; } async handle({ @@ -137,12 +139,12 @@ export class RouteTriggerService { }; const result = - await this.serverlessFunctionService.executeOneServerlessFunction( - routeTriggerWithPathParams.routeTrigger.serverlessFunction.id, - routeTriggerWithPathParams.routeTrigger.workspaceId, - executionParams, - 'draft', - ); + await this.serverlessFunctionService.executeOneServerlessFunction({ + id: routeTriggerWithPathParams.routeTrigger.serverlessFunction.id, + workspaceId: routeTriggerWithPathParams.routeTrigger.workspaceId, + payload: executionParams, + version: 'draft', + }); if (!isDefined(result)) { return result; diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/jobs/serverless-function-trigger.job.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/jobs/serverless-function-trigger.job.ts index 5a22d0ec3c1..ec32dd3acf2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/jobs/serverless-function-trigger.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/jobs/serverless-function-trigger.job.ts @@ -22,11 +22,11 @@ export class ServerlessFunctionTriggerJob { @Process(ServerlessFunctionTriggerJob.name) async handle(data: ServerlessFunctionTriggerJobData) { - await this.serverlessFunctionService.executeOneServerlessFunction( - data.serverlessFunctionId, - data.workspaceId, - data.payload || {}, - 'draft', - ); + await this.serverlessFunctionService.executeOneServerlessFunction({ + id: data.serverlessFunctionId, + workspaceId: data.workspaceId, + payload: data.payload || {}, + version: 'draft', + }); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts index c5dee29d806..ebc3c037c28 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts @@ -24,6 +24,7 @@ import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverl import { WorkspaceFlatServerlessFunctionMapCacheService } from 'src/engine/metadata-modules/serverless-function/services/workspace-flat-serverless-function-map-cache.service'; import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.modu WorkspaceMigrationV2Module, ServerlessFunctionLayerModule, SubscriptionsModule, + TokenModule, ], providers: [ ServerlessFunctionService, diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts index 96b3897432a..44e8329ad7a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts @@ -163,12 +163,12 @@ export class ServerlessFunctionResolver { try { const { id, payload, version } = input; - return await this.serverlessFunctionService.executeOneServerlessFunction( + return await this.serverlessFunctionService.executeOneServerlessFunction({ id, workspaceId, payload, version, - ); + }); } catch (error) { serverlessFunctionGraphQLApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts index ea942a8d3bc..f1d3734926a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts @@ -8,6 +8,10 @@ import { isDefined } from 'twenty-shared/utils'; import { IsNull, Not, Repository } from 'typeorm'; import { RedisPubSub } from 'graphql-redis-subscriptions'; import { Sources } from 'twenty-shared/types'; +import { + DEFAULT_API_URL_NAME, + DEFAULT_API_KEY_NAME, +} from 'twenty-shared/application'; import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; import { type ServerlessExecuteResult } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface'; @@ -33,6 +37,12 @@ import { WorkflowVersionStepExceptionCode, } from 'src/modules/workflow/common/exceptions/workflow-version-step.exception'; import { SERVERLESS_FUNCTION_LOGS_TRIGGER } from 'src/engine/metadata-modules/serverless-function/constants/serverless-function-logs-trigger'; +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; +import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { cleanServerUrl } from 'src/utils/clean-server-url'; + +const MIN_TOKEN_EXPIRATION_IN_SECONDS = 5; @Injectable() export class ServerlessFunctionService { @@ -45,6 +55,8 @@ export class ServerlessFunctionService { private readonly throttlerService: ThrottlerService, private readonly twentyConfigService: TwentyConfigService, private readonly auditService: AuditService, + private readonly accessTokenService: AccessTokenService, + private readonly applicationTokenService: ApplicationTokenService, @Inject('PUB_SUB') private readonly pubSub: RedisPubSub, ) {} @@ -86,12 +98,17 @@ export class ServerlessFunctionService { } } - async executeOneServerlessFunction( - id: string, - workspaceId: string, - payload: object, + async executeOneServerlessFunction({ + id, + workspaceId, + payload, version = 'latest', - ): Promise { + }: { + id: string; + workspaceId: string; + payload: object; + version?: string; + }): Promise { await this.throttleExecution(workspaceId); const functionToExecute = @@ -102,14 +119,45 @@ export class ServerlessFunctionService { }, relations: [ 'serverlessFunctionLayer', - 'application', 'application.applicationVariables', ], }); + const applicationAccessToken = isDefined(functionToExecute.applicationId) + ? await this.applicationTokenService.generateApplicationToken({ + workspaceId, + applicationId: functionToExecute.applicationId, + expiresInSeconds: Math.max( + functionToExecute.timeoutSeconds, + MIN_TOKEN_EXPIRATION_IN_SECONDS, + ), + }) + : undefined; + + const baseUrl = cleanServerUrl(this.twentyConfigService.get('SERVER_URL')); + + const envVariables = { + ...(isDefined(baseUrl) + ? { + [DEFAULT_API_URL_NAME]: baseUrl, + } + : {}), + ...(isDefined(applicationAccessToken) + ? { + [DEFAULT_API_KEY_NAME]: applicationAccessToken.token, + } + : {}), + ...buildEnvVar(functionToExecute), + }; + const resultServerlessFunction = await this.callWithTimeout({ callback: () => - this.serverlessService.execute(functionToExecute, payload, version), + this.serverlessService.execute({ + serverlessFunction: functionToExecute, + payload, + version, + env: envVariables, + }), timeoutMs: functionToExecute.timeoutSeconds * 1000, }); diff --git a/packages/twenty-server/src/engine/middlewares/middleware.service.ts b/packages/twenty-server/src/engine/middlewares/middleware.service.ts index 5cb042da4c6..71bd0e8094e 100644 --- a/packages/twenty-server/src/engine/middlewares/middleware.service.ts +++ b/packages/twenty-server/src/engine/middlewares/middleware.service.ts @@ -7,7 +7,6 @@ import { isDefined } from 'twenty-shared/utils'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; -import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { getAuthExceptionRestStatus } from 'src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @@ -21,6 +20,7 @@ import { } from 'src/engine/utils/global-exception-handler.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { type CustomException } from 'src/utils/custom-exception'; +import { bindDataToRequestObject } from 'src/engine/utils/bind-data-to-request-object.util'; @Injectable() export class MiddlewareService { @@ -116,7 +116,7 @@ export class MiddlewareService { throw new Error('No data sources found'); } - this.bindDataToRequestObject(data, request, metadataVersion); + bindDataToRequestObject(data, request, metadataVersion); } public async hydrateGraphqlRequest(request: Request) { @@ -135,35 +135,13 @@ export class MiddlewareService { ) : undefined; - this.bindDataToRequestObject(data, request, metadataVersion); + bindDataToRequestObject(data, request, metadataVersion); } private hasErrorStatus(error: unknown): error is { status: number } { return isDefined((error as { status: number })?.status); } - private bindDataToRequestObject( - data: AuthContext, - request: Request, - metadataVersion: number | undefined, - ) { - request.user = data.user; - request.apiKey = data.apiKey; - request.userWorkspace = data.userWorkspace; - request.workspace = data.workspace; - request.workspaceId = data.workspace?.id; - request.workspaceMetadataVersion = metadataVersion; - request.workspaceMemberId = data.workspaceMemberId; - request.userWorkspaceId = data.userWorkspaceId; - request.authProvider = data.authProvider; - request.impersonationContext = data.impersonationContext; - - request.locale = - data.userWorkspace?.locale ?? - (request.headers['x-locale'] as keyof typeof APP_LOCALES) ?? - SOURCE_LOCALE; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any private getStatus(error: any): number { if (this.hasErrorStatus(error)) { diff --git a/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts b/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts new file mode 100644 index 00000000000..1b701da9cec --- /dev/null +++ b/packages/twenty-server/src/engine/utils/bind-data-to-request-object.util.ts @@ -0,0 +1,27 @@ +import { type Request } from 'express'; +import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; + +export const bindDataToRequestObject = ( + data: AuthContext, + request: Request, + metadataVersion: number | undefined, +) => { + request.user = data.user; + request.apiKey = data.apiKey; + request.application = data.application; + request.userWorkspace = data.userWorkspace; + request.workspace = data.workspace; + request.workspaceId = data.workspace?.id; + request.workspaceMetadataVersion = metadataVersion; + request.workspaceMemberId = data.workspaceMemberId; + request.userWorkspaceId = data.userWorkspaceId; + request.authProvider = data.authProvider; + request.impersonationContext = data.impersonationContext; + + request.locale = + data.userWorkspace?.locale ?? + (request.headers['x-locale'] as keyof typeof APP_LOCALES) ?? + SOURCE_LOCALE; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts index ecdc8feac45..89608975955 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action.ts @@ -48,12 +48,12 @@ export class CodeWorkflowAction implements WorkflowAction { const { workspaceId } = runInfo; const result = - await this.serverlessFunctionService.executeOneServerlessFunction( - workflowActionInput.serverlessFunctionId, + await this.serverlessFunctionService.executeOneServerlessFunction({ + id: workflowActionInput.serverlessFunctionId, workspaceId, - workflowActionInput.serverlessFunctionInput, - workflowActionInput.serverlessFunctionVersion, - ); + payload: workflowActionInput.serverlessFunctionInput, + version: workflowActionInput.serverlessFunctionVersion, + }); if (result.error) { return { error: result.error.errorMessage }; diff --git a/packages/twenty-server/src/utils/clean-server-url.ts b/packages/twenty-server/src/utils/clean-server-url.ts new file mode 100644 index 00000000000..358cfe7e6d7 --- /dev/null +++ b/packages/twenty-server/src/utils/clean-server-url.ts @@ -0,0 +1,6 @@ +export const cleanServerUrl = (serverUrlEnv?: string) => { + if (serverUrlEnv?.endsWith('/')) + return serverUrlEnv.substring(0, serverUrlEnv.length - 1); + + return serverUrlEnv; +}; diff --git a/packages/twenty-server/src/utils/get-server-url.ts b/packages/twenty-server/src/utils/get-server-url.ts index 5d19e8b584a..a5b870bb50c 100644 --- a/packages/twenty-server/src/utils/get-server-url.ts +++ b/packages/twenty-server/src/utils/get-server-url.ts @@ -1,9 +1,11 @@ -export const getServerUrl = ( - serverUrlEnv: string, - serverUrlFallback: string, -): string => { - if (serverUrlEnv?.endsWith('/')) - return serverUrlEnv.substring(0, serverUrlEnv.length - 1); +import { cleanServerUrl } from 'src/utils/clean-server-url'; - return serverUrlEnv || serverUrlFallback; +export const getServerUrl = ({ + serverUrlEnv, + serverUrlFallback, +}: { + serverUrlEnv?: string; + serverUrlFallback: string; +}): string => { + return cleanServerUrl(serverUrlEnv) || serverUrlFallback; }; diff --git a/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-acme-member-role.util.ts b/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-acme-member-role.util.ts deleted file mode 100644 index ab68a0c767e..00000000000 --- a/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-acme-member-role.util.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type ASTNode, print } from 'graphql'; -import request from 'supertest'; - -type GraphqlOperation = { - query: ASTNode; - variables?: Record; -}; - -export const makeGraphqlAPIRequestWithAcmeMemberRole = ( - graphqlOperation: GraphqlOperation, -) => { - const client = request(`http://localhost:${APP_PORT}`); - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ACME_JONY_MEMBER_ACCESS_TOKEN}`) - .send({ - query: print(graphqlOperation.query), - variables: graphqlOperation.variables || {}, - }); -}; diff --git a/packages/twenty-shared/src/application/applicationManifestType.ts b/packages/twenty-shared/src/application/applicationManifestType.ts index 08bc2807616..01acb474bdb 100644 --- a/packages/twenty-shared/src/application/applicationManifestType.ts +++ b/packages/twenty-shared/src/application/applicationManifestType.ts @@ -4,10 +4,12 @@ import { type Application, } from '@/application'; import { type Sources } from '@/types'; +import { type RoleManifest } from '@/application/roleManifestType'; export type ApplicationManifest = { application: Application; objects: ObjectManifest[]; serverlessFunctions: ServerlessFunctionManifest[]; + roles?: RoleManifest[]; sources: Sources; }; diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index 71374cb7b99..20f36cefb25 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -1,46 +1,10 @@ import { type ApplicationVariables } from '@/application'; -import { type PermissionFlagType } from '@/constants'; +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; -type ObjectPermission = { - objectMetadataId: string; - canReadObjectRecords?: boolean; - canUpdateObjectRecords?: boolean; - canSoftDeleteObjectRecords?: boolean; - canDestroyObjectRecords?: boolean; -}; - -type FieldPermission = { - objectMetadataId: string; - fieldMetadataId: string; - canReadFieldValue?: boolean; - canUpdateFieldValue?: boolean; -}; - -type Role = { - label: string; - description?: string; - icon?: string; - canUpdateAllSettings?: boolean; - canAccessAllTools?: boolean; - canReadAllObjectRecords?: boolean; - canUpdateAllObjectRecords?: boolean; - canSoftDeleteAllObjectRecords?: boolean; - canDestroyAllObjectRecords?: boolean; - canBeAssignedToUsers?: boolean; - canBeAssignedToAgents?: boolean; - canBeAssignedToApiKeys?: boolean; - canBeAssignedToApplications?: boolean; - universalIdentifier: string; - objectPermissions?: ObjectPermission[]; - fieldPermissions?: FieldPermission[]; - permissionFlags?: PermissionFlagType[]; -}; - -export type Application = { - universalIdentifier: string; +export type Application = SyncableEntityOptions & { displayName?: string; description?: string; icon?: string; applicationVariables?: ApplicationVariables; - applicationRole?: Role; + functionRoleUniversalIdentifier?: string; }; diff --git a/packages/twenty-shared/src/application/applicationVariablesType.ts b/packages/twenty-shared/src/application/applicationVariablesType.ts index 008747cf665..4e205ee73fd 100644 --- a/packages/twenty-shared/src/application/applicationVariablesType.ts +++ b/packages/twenty-shared/src/application/applicationVariablesType.ts @@ -1,5 +1,6 @@ -type ApplicationVariable = { - universalIdentifier: string; +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; + +type ApplicationVariable = SyncableEntityOptions & { value?: string; description?: string; isSecret?: boolean; diff --git a/packages/twenty-shared/src/application/constants/DefaultApiKeyName.ts b/packages/twenty-shared/src/application/constants/DefaultApiKeyName.ts new file mode 100644 index 00000000000..c54272db383 --- /dev/null +++ b/packages/twenty-shared/src/application/constants/DefaultApiKeyName.ts @@ -0,0 +1 @@ +export const DEFAULT_API_KEY_NAME = 'TWENTY_API_KEY'; diff --git a/packages/twenty-shared/src/application/constants/DefaultApiUrlName.ts b/packages/twenty-shared/src/application/constants/DefaultApiUrlName.ts new file mode 100644 index 00000000000..73a0ae48aa5 --- /dev/null +++ b/packages/twenty-shared/src/application/constants/DefaultApiUrlName.ts @@ -0,0 +1 @@ +export const DEFAULT_API_URL_NAME = 'TWENTY_API_URL'; diff --git a/packages/twenty-shared/src/application/fieldManifestType.ts b/packages/twenty-shared/src/application/fieldManifestType.ts index 85903d1bb2e..00c4f2eb03c 100644 --- a/packages/twenty-shared/src/application/fieldManifestType.ts +++ b/packages/twenty-shared/src/application/fieldManifestType.ts @@ -1,14 +1,23 @@ -import { type FieldMetadataType } from '@/types'; +import { + type FieldMetadataType, + type FieldMetadataSettings, + type FieldMetadataOptions, + type FieldMetadataDefaultValue, +} from '@/types'; +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; -export type FieldManifest = { - universalIdentifier: string; - type: FieldMetadataType; +export type FieldManifest< + T extends FieldMetadataType = Exclude< + FieldMetadataType, + FieldMetadataType.RELATION + >, +> = SyncableEntityOptions & { + type: T; label: string; description?: string; icon?: string; - defaultValue?: any; - options?: any; - settings?: any; + defaultValue?: FieldMetadataDefaultValue; + options?: FieldMetadataOptions; + settings?: FieldMetadataSettings; isNullable?: boolean; - isFieldUiReadOnly?: boolean; }; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 3c95665fafa..12d30361edc 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -10,9 +10,12 @@ export type { ApplicationManifest } from './applicationManifestType'; export type { Application } from './applicationType'; export type { ApplicationVariables } from './applicationVariablesType'; +export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName'; +export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName'; export type { FieldManifest } from './fieldManifestType'; export type { ObjectManifest } from './objectManifestType'; export type { PackageJson } from './packageJsonType'; +export type { RoleManifest } from './roleManifestType'; export type { ServerlessFunctionManifest, DatabaseEventTrigger, @@ -20,3 +23,4 @@ export type { RouteTrigger, ServerlessFunctionTriggerManifest, } from './serverlessFunctionManifestType'; +export type { SyncableEntityOptions } from './syncableEntityOptionsType'; diff --git a/packages/twenty-shared/src/application/objectManifestType.ts b/packages/twenty-shared/src/application/objectManifestType.ts index 78ff7cca07e..2bffd0d426f 100644 --- a/packages/twenty-shared/src/application/objectManifestType.ts +++ b/packages/twenty-shared/src/application/objectManifestType.ts @@ -1,7 +1,7 @@ import { type FieldManifest } from '@/application'; +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; -export type ObjectManifest = { - universalIdentifier: string; +export type ObjectManifest = SyncableEntityOptions & { nameSingular: string; namePlural: string; labelSingular: string; diff --git a/packages/twenty-shared/src/application/roleManifestType.ts b/packages/twenty-shared/src/application/roleManifestType.ts new file mode 100644 index 00000000000..876ae3cc74c --- /dev/null +++ b/packages/twenty-shared/src/application/roleManifestType.ts @@ -0,0 +1,63 @@ +import { type PermissionFlagType } from '@/constants'; +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; + +type WithObjectIdentifier = { + objectUniversalIdentifier: string; + objectNameSingular?: never; +}; + +type WithObjectName = { + objectNameSingular: string; + objectUniversalIdentifier?: never; +}; + +type BaseObjectPermission = { + canReadObjectRecords?: boolean; + canUpdateObjectRecords?: boolean; + canSoftDeleteObjectRecords?: boolean; + canDestroyObjectRecords?: boolean; +}; + +type ObjectPermission = + | (BaseObjectPermission & WithObjectIdentifier) + | (BaseObjectPermission & WithObjectName); + +type WithFieldIdentifier = { + fieldUniversalIdentifier: string; + fieldName?: never; +}; + +type WithFieldName = { + fieldName: string; + fieldUniversalIdentifier?: never; +}; + +type BaseFieldPermission = { + canReadFieldValue?: boolean; + canUpdateFieldValue?: boolean; +}; + +type FieldPermission = + | (BaseFieldPermission & WithObjectIdentifier & WithFieldIdentifier) + | (BaseFieldPermission & WithObjectIdentifier & WithFieldName) + | (BaseFieldPermission & WithObjectName & WithFieldIdentifier) + | (BaseFieldPermission & WithObjectName & WithFieldName); + +export type RoleManifest = SyncableEntityOptions & { + label: string; + description?: string; + icon?: string; + canUpdateAllSettings?: boolean; + canAccessAllTools?: boolean; + canReadAllObjectRecords?: boolean; + canUpdateAllObjectRecords?: boolean; + canSoftDeleteAllObjectRecords?: boolean; + canDestroyAllObjectRecords?: boolean; + canBeAssignedToUsers?: boolean; + canBeAssignedToAgents?: boolean; + canBeAssignedToApiKeys?: boolean; + canBeAssignedToApplications?: boolean; + objectPermissions?: ObjectPermission[]; + fieldPermissions?: FieldPermission[]; + permissionFlags?: PermissionFlagType[]; +}; diff --git a/packages/twenty-shared/src/application/serverlessFunctionManifestType.ts b/packages/twenty-shared/src/application/serverlessFunctionManifestType.ts index b21c01f517e..9bf91a006d9 100644 --- a/packages/twenty-shared/src/application/serverlessFunctionManifestType.ts +++ b/packages/twenty-shared/src/application/serverlessFunctionManifestType.ts @@ -1,7 +1,7 @@ import { type HTTPMethod } from '@/types'; +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; -export type ServerlessFunctionManifest = { - universalIdentifier: string; +export type ServerlessFunctionManifest = SyncableEntityOptions & { name?: string; description?: string; timeoutSeconds?: number; @@ -23,10 +23,9 @@ export type CronTrigger = { export type RouteTrigger = { type: 'route'; path: string; - httpMethod: HTTPMethod; + httpMethod: `${HTTPMethod}`; isAuthRequired: boolean; }; -export type ServerlessFunctionTriggerManifest = { - universalIdentifier: string; -} & (CronTrigger | DatabaseEventTrigger | RouteTrigger); +export type ServerlessFunctionTriggerManifest = SyncableEntityOptions & + (CronTrigger | DatabaseEventTrigger | RouteTrigger); diff --git a/packages/twenty-shared/src/application/syncableEntityOptionsType.ts b/packages/twenty-shared/src/application/syncableEntityOptionsType.ts new file mode 100644 index 00000000000..466e3420344 --- /dev/null +++ b/packages/twenty-shared/src/application/syncableEntityOptionsType.ts @@ -0,0 +1 @@ +export type SyncableEntityOptions = { universalIdentifier: string }; diff --git a/packages/twenty-shared/src/types/composite-types/actor.composite-type.ts b/packages/twenty-shared/src/types/composite-types/actor.composite-type.ts index afdb3b62528..1d3c3dbcc29 100644 --- a/packages/twenty-shared/src/types/composite-types/actor.composite-type.ts +++ b/packages/twenty-shared/src/types/composite-types/actor.composite-type.ts @@ -15,6 +15,7 @@ export enum FieldActorSource { MANUAL = 'MANUAL', SYSTEM = 'SYSTEM', WEBHOOK = 'WEBHOOK', + APPLICATION = 'APPLICATION', } export const actorCompositeType: CompositeType = { diff --git a/packages/twenty-shared/src/utils/__tests__/compute-diff-between-objects.test.ts b/packages/twenty-shared/src/utils/__tests__/compute-diff-between-objects.test.ts index 0ca3238d210..15420c25837 100644 --- a/packages/twenty-shared/src/utils/__tests__/compute-diff-between-objects.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/compute-diff-between-objects.test.ts @@ -99,7 +99,7 @@ describe('computeDiffBetweenObjects', () => { it('should restore and update deleted objects', () => { const existingObjects = [ - { id: '1', name: 'Object 1', deletedAt: new Date('2024-01-01') }, + { id: '1', name: 'Object 1', deletedAt: '2024-01-01' }, ]; const receivedObjects = [{ id: '1', name: 'Restored Object 1' }]; @@ -120,7 +120,7 @@ describe('computeDiffBetweenObjects', () => { it('should not include deleted objects in idsToDelete', () => { const existingObjects = [ { id: '1', name: 'Object 1', deletedAt: null }, - { id: '2', name: 'Object 2', deletedAt: new Date('2024-01-01') }, + { id: '2', name: 'Object 2', deletedAt: '2024-01-01' }, ]; const receivedObjects: { id: string; name: string }[] = [];