Add defineApplicationRole method (#20314)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Rahman
2026-05-08 00:25:13 +05:30
committed by GitHub
parent 1553ff2857
commit 4aca4d1143
20 changed files with 237 additions and 50 deletions
@@ -4,12 +4,10 @@ import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -1,11 +1,11 @@
import { defineRole } from 'twenty-sdk/define';
import { defineApplicationRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineRole({
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
@@ -13,7 +13,6 @@ Every app must have exactly one `defineApplication` call. It declares:
```ts src/application-config.ts
import { defineApplication } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export default defineApplication({
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
@@ -27,19 +26,19 @@ export default defineApplication({
isSecret: false,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
```
Notes:
- `universalIdentifier` fields are deterministic IDs you own. Generate them once and keep them stable across syncs.
- `applicationVariables` become environment variables for your functions and front components (e.g., `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
- `defaultRoleUniversalIdentifier` must reference a role defined with [`defineRole()`](/developers/extend/apps/config/roles).
- The default role is detected automatically from the role file marked with [`defineApplicationRole()`](/developers/extend/apps/config/roles) — you do not need to reference it from `defineApplication()`.
- Pre-install and post-install functions are detected automatically during the manifest build — you do not need to reference them in `defineApplication()`.
- Passing `defaultRoleUniversalIdentifier` explicitly is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`.
## Default function role
The `defaultRoleUniversalIdentifier` controls what the app's logic functions and front components can access:
The role declared with [`defineApplicationRole()`](/developers/extend/apps/config/roles) controls what the app's logic functions and front components can access:
- The runtime token injected as `TWENTY_APP_ACCESS_TOKEN` is derived from this role.
- The typed API client is restricted to the permissions granted to that role.
@@ -4,7 +4,7 @@ description: Declare what objects and fields your app's logic functions and fron
icon: "shield-halved"
---
A **role** is a permission set: which objects an app can read or write, which fields it can see, and which platform-level capabilities it can use. Every app's logic functions and front components inherit the permissions of the role declared as `defaultRoleUniversalIdentifier` in [`defineApplication`](/developers/extend/apps/config/application).
A **role** is a permission set: which objects an app can read or write, which fields it can see, and which platform-level capabilities it can use. Every app's logic functions and front components inherit the permissions of the role marked with `defineApplicationRole()` (see [The default function role](#the-default-function-role) below).
```ts src/roles/restricted-company-role.ts
import {
@@ -51,15 +51,15 @@ export default defineRole({
## The default function role
When you scaffold a new app, the CLI creates a default role file:
When you scaffold a new app, the CLI creates a default role file declared with `defineApplicationRole()`:
```ts src/roles/default-role.ts
import { defineRole, PermissionFlag } from 'twenty-sdk/define';
import { defineApplicationRole, PermissionFlag } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
export default defineRole({
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Default function role',
description: 'Default role for function Twenty client',
@@ -77,10 +77,12 @@ export default defineRole({
});
```
This role's `universalIdentifier` is referenced from `application-config.ts` as `defaultRoleUniversalIdentifier`:
`defineApplicationRole()` is a thin wrapper around `defineRole()` that flags **the** role used as your application's default at install time. Validation is identical to `defineRole`, but the build pipeline auto-wires its `universalIdentifier` into the application manifest's `defaultRoleUniversalIdentifier` — so you do not need to reference it from [`defineApplication`](/developers/extend/apps/config/application) yourself.
- **`*.role.ts`** declares what the role can do.
- **`application-config.ts`** points to that role so your functions inherit its permissions.
Notes:
- Exactly **one** `defineApplicationRole(...)` is allowed per app — the manifest build will fail if it finds more than one.
- Use `defineRole()` (not `defineApplicationRole()`) for any **additional** roles your app ships.
- Setting `defaultRoleUniversalIdentifier` explicitly on `defineApplication()` is still supported for backward compatibility, but is deprecated in favor of `defineApplicationRole()`.
## Best practices
@@ -52,7 +52,6 @@ export default defineApplication({
universalIdentifier: '...',
displayName: 'Linear',
description: 'Connect Linear to Twenty.',
defaultRoleUniversalIdentifier: '...',
// OAuth client credentials live on the app registration (one OAuth app per
// Twenty server, configured by the admin) — not per-workspace. Declare them
// as serverVariables so the admin can fill them in once for all installs.
@@ -372,5 +372,5 @@ Key points:
- `TWENTY_API_URL` — Base URL of the Twenty API
- `TWENTY_APP_ACCESS_TOKEN` — Short-lived key scoped to your application's default function role
You do **not** need to pass these to the clients — they read from `process.env` automatically. The API key's permissions are determined by the role referenced in `defaultRoleUniversalIdentifier` in your `application-config.ts`.
You do **not** need to pass these to the clients — they read from `process.env` automatically. The API key's permissions are determined by the role declared with `defineApplicationRole()` (or referenced via `defaultRoleUniversalIdentifier` in `application-config.ts`).
</Note>
@@ -187,7 +187,6 @@ export default defineApplication({
universalIdentifier: '...',
displayName: 'My App',
description: 'A great app',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
logoUrl: 'public/logo.png',
screenshots: [
'public/screenshot-1.png',
@@ -33,6 +33,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"createValidationResult",
"defineAgent",
"defineApplication",
"defineApplicationRole",
"defineCommandMenuItem",
"defineConnectionProvider",
"defineField",
@@ -46,6 +46,15 @@ describe('extractDefineEntity', () => {
expect(result).toBe('defineRole');
});
it('should detect defineApplicationRole in default export', () => {
const fileContent = `
import { defineApplicationRole } from 'twenty-sdk/define';
export default defineApplicationRole({ universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', label: 'Default function role' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineApplicationRole');
});
it('should detect defineFrontComponent in default export', () => {
const fileContent = `
import { defineFrontComponent } from 'twenty-sdk/define';
@@ -47,6 +47,6 @@ export const buildAndValidateManifest = async (
success: true,
manifest: result.manifest,
filePaths: result.filePaths,
warnings: validation.warnings,
warnings: [...result.warnings, ...validation.warnings],
};
};
@@ -6,13 +6,18 @@ import {
TargetFunction,
} from '@/cli/utilities/build/manifest/manifest-extract-config';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import { addMissingFieldOptionIds } from '@/cli/utilities/build/manifest/utils/add-missing-field-option-ids';
import { fromRoleConfigToRoleManifest } from '@/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest';
import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields';
import { type ApplicationConfig, type LogicFunctionConfig } from '@/sdk/define';
import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
import { type PostInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/post-install-logic-function-config';
import { type PreInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/pre-install-logic-function-config';
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
import { type PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
import { type RoleConfig } from '@/sdk/define/roles/role-config';
import { type ViewConfig } from '@/sdk/define/views/view-config';
import { readFile } from 'node:fs/promises';
import { basename, extname, relative } from 'path';
@@ -43,11 +48,6 @@ import {
jsonSchemaToInputSchema,
} from 'twenty-shared/logic-function';
import { assertUnreachable } from 'twenty-shared/utils';
import { addMissingFieldOptionIds } from '@/cli/utilities/build/manifest/utils/add-missing-field-option-ids';
import { type PostInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/post-install-logic-function-config';
import { type PreInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/pre-install-logic-function-config';
import { fromRoleConfigToRoleManifest } from '@/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest';
import { type RoleConfig } from '@/sdk/define/roles/role-config';
const loadSources = async (appPath: string): Promise<string[]> => {
return await glob(['**/*.ts', '**/*.tsx'], {
@@ -72,11 +72,13 @@ export const buildManifest = async (
manifest: Manifest | null;
filePaths: EntityFilePaths;
errors: string[];
warnings: string[];
}> => {
const filePaths = await loadSources(appPath);
const errors: string[] = [];
const warnings: string[] = [];
let application: ApplicationManifest | undefined;
let applicationConfig: ApplicationConfig | undefined;
const objects: ObjectManifest[] = [];
const fields: FieldManifest[] = [];
const roles: RoleManifest[] = [];
@@ -95,6 +97,7 @@ export const buildManifest = async (
[];
const preInstallLogicFunctions: PreInstallLogicFunctionApplicationManifest[] =
[];
const applicationRoleUniversalIdentifiers: string[] = [];
const applicationFilePaths: string[] = [];
const objectsFilePaths: string[] = [];
const fieldsFilePaths: string[] = [];
@@ -130,12 +133,9 @@ export const buildManifest = async (
filePath,
});
application = {
...extract.config,
yarnLockChecksum: null,
packageJsonChecksum: null,
};
applicationConfig = extract.config;
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
applicationFilePaths.push(relativePath);
break;
}
@@ -172,6 +172,7 @@ export const buildManifest = async (
fields.push(...reverseRelationFields);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
objectsFilePaths.push(relativePath);
break;
}
@@ -183,6 +184,7 @@ export const buildManifest = async (
const fieldConfig = addMissingFieldOptionIds(extract.config);
fields.push(fieldConfig);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
fieldsFilePaths.push(relativePath);
break;
}
@@ -194,7 +196,15 @@ export const buildManifest = async (
const roleConfig = fromRoleConfigToRoleManifest(extract.config);
roles.push(roleConfig);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
rolesFilePaths.push(relativePath);
if (targetFunctionName === TargetFunction.DefineApplicationRole) {
applicationRoleUniversalIdentifiers.push(
extract.config.universalIdentifier,
);
}
break;
}
case ManifestEntityKey.Skills: {
@@ -204,6 +214,7 @@ export const buildManifest = async (
});
skills.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
skillsFilePaths.push(relativePath);
break;
}
@@ -214,6 +225,7 @@ export const buildManifest = async (
});
agents.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
agentsFilePaths.push(relativePath);
break;
}
@@ -225,6 +237,7 @@ export const buildManifest = async (
});
connectionProviders.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
connectionProvidersFilePaths.push(relativePath);
break;
}
@@ -235,6 +248,7 @@ export const buildManifest = async (
});
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
const { handler: _, ...rest } = extract.config;
@@ -323,6 +337,7 @@ export const buildManifest = async (
});
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
const { component, ...rest } = extract.config;
@@ -354,6 +369,7 @@ export const buildManifest = async (
views.push(viewManifest);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
viewsFilePaths.push(relativePath);
break;
}
@@ -365,6 +381,7 @@ export const buildManifest = async (
});
navigationMenuItems.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
navigationMenuItemsFilePaths.push(relativePath);
break;
}
@@ -380,6 +397,7 @@ export const buildManifest = async (
pageLayouts.push(pageLayoutManifest);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
pageLayoutsFilePaths.push(relativePath);
break;
}
@@ -395,6 +413,7 @@ export const buildManifest = async (
pageLayoutTabs.push(pageLayoutTabManifest);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
pageLayoutTabsFilePaths.push(relativePath);
break;
}
@@ -408,6 +427,7 @@ export const buildManifest = async (
extract.config as unknown as CommandMenuItemManifest,
);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
commandMenuItemsFilePaths.push(relativePath);
break;
}
@@ -434,7 +454,7 @@ export const buildManifest = async (
publicAssetsFilePaths.push(relativePath);
}
if (!application) {
if (!applicationConfig) {
errors.push(
'Cannot build application, please export default defineApplication() to define an application',
);
@@ -452,27 +472,46 @@ export const buildManifest = async (
);
}
if (application && postInstallLogicFunctions.length >= 1) {
application = {
...application,
postInstallLogicFunction: postInstallLogicFunctions[0],
};
if (applicationRoleUniversalIdentifiers.length > 1) {
errors.push('Only one defineApplicationRole is allowed per application');
}
if (application && preInstallLogicFunctions.length >= 1) {
application = {
...application,
preInstallLogicFunction: preInstallLogicFunctions[0],
};
const resolvedDefaultRoleUniversalIdentifier =
applicationConfig?.defaultRoleUniversalIdentifier ??
(applicationRoleUniversalIdentifiers.length === 1
? applicationRoleUniversalIdentifiers[0]
: undefined);
if (applicationConfig && !resolvedDefaultRoleUniversalIdentifier) {
errors.push(
'Application must declare a default role: either pass `defaultRoleUniversalIdentifier` to defineApplication() or mark a role file with defineApplicationRole()',
);
}
const application: ApplicationManifest | undefined =
applicationConfig && resolvedDefaultRoleUniversalIdentifier
? {
...applicationConfig,
defaultRoleUniversalIdentifier:
resolvedDefaultRoleUniversalIdentifier,
yarnLockChecksum: null,
packageJsonChecksum: null,
...(postInstallLogicFunctions.length >= 1
? { postInstallLogicFunction: postInstallLogicFunctions[0] }
: {}),
...(preInstallLogicFunctions.length >= 1
? { preInstallLogicFunction: preInstallLogicFunctions[0] }
: {}),
}
: undefined;
const byId = <T extends { universalIdentifier: string }>(a: T, b: T) =>
a.universalIdentifier.localeCompare(b.universalIdentifier);
const byPath = <T extends { filePath: string }>(a: T, b: T) =>
a.filePath.localeCompare(b.filePath);
const manifest = !application
const manifest: Manifest | null = !application
? null
: {
application,
@@ -510,5 +549,5 @@ export const buildManifest = async (
commandMenuItems: commandMenuItemsFilePaths,
};
return { manifest, filePaths: entityFilePaths, errors };
return { manifest, filePaths: entityFilePaths, errors, warnings };
};
@@ -2,6 +2,7 @@ import * as ts from 'typescript';
export enum TargetFunction {
DefineApplication = 'defineApplication',
DefineApplicationRole = 'defineApplicationRole',
DefineField = 'defineField',
DefineLogicFunction = 'defineLogicFunction',
DefinePostInstallLogicFunction = 'definePostInstallLogicFunction',
@@ -44,6 +45,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
ManifestEntityKey
> = {
[TargetFunction.DefineApplication]: ManifestEntityKey.Application,
[TargetFunction.DefineApplicationRole]: ManifestEntityKey.Roles,
[TargetFunction.DefineField]: ManifestEntityKey.Fields,
[TargetFunction.DefineLogicFunction]: ManifestEntityKey.LogicFunctions,
[TargetFunction.DefinePostInstallLogicFunction]:
@@ -41,6 +41,38 @@ describe('defineApplication', () => {
);
});
it('should accept config without defaultRoleUniversalIdentifier (auto-wired by defineApplicationRole)', () => {
const config = {
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
displayName: 'My App',
description: 'My app description',
};
const result = defineApplication(config);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
expect(result.warnings).toEqual([]);
expect(result.config?.defaultRoleUniversalIdentifier).toBeUndefined();
});
it('should warn that defaultRoleUniversalIdentifier is deprecated when provided', () => {
const result = defineApplication({
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
displayName: 'My App',
description: 'My app description',
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
});
const warnings = result.warnings ?? [];
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toMatch(/deprecated/i);
expect(warnings[0]).toMatch(/defineApplicationRole/);
});
it('should return error when universalIdentifier is missing', () => {
const config = {
displayName: 'My App',
@@ -6,4 +6,10 @@ export type ApplicationConfig = Omit<
| 'yarnLockChecksum'
| 'postInstallLogicFunction'
| 'preInstallLogicFunction'
>;
| 'defaultRoleUniversalIdentifier'
> & {
/**
* @deprecated Use `defineApplicationRole()` in your role file instead.
*/
defaultRoleUniversalIdentifier?: string;
};
@@ -1,24 +1,28 @@
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
import { type ApplicationConfig } from '@/sdk/define/application/application-config';
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
export const defineApplication: DefineEntity<ApplicationConfig> = (config) => {
const errors = [];
const warnings = [];
if (!config.universalIdentifier) {
errors.push('Application must have a universalIdentifier');
}
if (!config.defaultRoleUniversalIdentifier) {
errors.push('Application must have a defaultRoleUniversalIdentifier');
}
if (!config.displayName || config.displayName.length === 0) {
errors.push('Application must have a non empty display name');
}
if (config.defaultRoleUniversalIdentifier) {
warnings.push(
'`defaultRoleUniversalIdentifier` on defineApplication() is deprecated. Use defineApplicationRole() in your role file instead.',
);
}
return createValidationResult({
config,
errors,
warnings,
});
};
@@ -21,6 +21,7 @@ export type ValidationResult<T> = {
success: boolean;
config: T;
errors: string[];
warnings?: string[];
};
export type DefinableEntity =
@@ -3,11 +3,14 @@ import { type ValidationResult } from '@/sdk/define/common/types/define-entity.t
export const createValidationResult = <T>({
config,
errors = [],
warnings = [],
}: {
config: T;
errors: string[];
warnings?: string[];
}): ValidationResult<T> => ({
success: errors.length === 0,
config,
errors,
warnings,
});
@@ -84,6 +84,7 @@ export type {
PageLayoutWidgetManifest,
} from 'twenty-shared/application';
export { defineApplicationRole } from '@/sdk/define/roles/define-application-role';
export { defineRole } from '@/sdk/define/roles/define-role';
export { PermissionFlag } from '@/sdk/define/roles/permission-flag-type';
@@ -0,0 +1,86 @@
import { defineApplicationRole } from '@/sdk/define';
describe('defineApplicationRole', () => {
const validConfig = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
description: 'Default role for function Twenty client',
};
it('should return successful validation result when valid', () => {
const result = defineApplicationRole(validConfig);
expect(result.success).toBe(true);
expect(result.config).toEqual(validConfig);
expect(result.errors).toEqual([]);
});
it('should pass through all optional fields', () => {
const config = {
...validConfig,
icon: 'IconShield',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
};
const result = defineApplicationRole(config);
expect(result.success).toBe(true);
expect(result.config?.icon).toBe('IconShield');
expect(result.config?.canReadAllObjectRecords).toBe(true);
});
it('should accept permissionFlags', () => {
const config = {
...validConfig,
permissionFlags: ['UPLOAD_FILE'],
};
const result = defineApplicationRole(config as any);
expect(result.success).toBe(true);
expect(result.config?.permissionFlags).toHaveLength(1);
});
it('should return error when universalIdentifier is missing', () => {
const config = {
label: 'Default function role',
};
const result = defineApplicationRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Role must have a universalIdentifier');
});
it('should return error when label is missing', () => {
const config = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
};
const result = defineApplicationRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Role must have a label');
});
it('should return error when objectPermission has no objectUniversalIdentifier', () => {
const config = {
...validConfig,
objectPermissions: [
{
canReadObjectRecords: true,
},
],
};
const result = defineApplicationRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Object permission must have an objectUniversalIdentifier',
);
});
});
@@ -0,0 +1,6 @@
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
import { defineRole } from '@/sdk/define/roles/define-role';
import { type RoleConfig } from '@/sdk/define/roles/role-config';
export const defineApplicationRole: DefineEntity<RoleConfig> = (config) =>
defineRole(config);