1895 extensibility v1 application tokens 3 (#16504)

- moves applicationRoleId to application entity
- add new `APPLICATION` FieldActorSource and `APPLICATION`
JwtTokenTypeEnum value
- create a new token with applicationId when executing a function
- when applicationId is in token, check for application.defaultRole
permissions
-use twenty-shared types in `twenty-sdk/application`
- create a new import from generate called "Twenty" that you can use
directly without having to set TWENTY_API_KEY AND TWENTY_API_URL (keep
metadata or core parameter only)
- provide to serverless unique one time BEARER TOKEN to run it

Result
<img width="977" height="566" alt="image"
src="https://github.com/user-attachments/assets/e78428a0-5b13-4975-aa13-58ee3b32450c"
/>

<img width="910" height="596" alt="image"
src="https://github.com/user-attachments/assets/6ec72bf5-7655-4093-a45e-ad269595a324"
/>

<img width="741" height="568" alt="image"
src="https://github.com/user-attachments/assets/7683944c-fd79-4417-8fb2-8e4815cc112f"
/>
This commit is contained in:
martmull
2025-12-15 17:44:23 +01:00
committed by GitHub
parent e33f18bfa8
commit e289f3056e
103 changed files with 1427 additions and 512 deletions
@@ -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<string, unknown>',
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<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
}
`;
await fs.appendFile(join(output, 'index.ts'), twentyClientContent);
}
}
@@ -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<string, ApplicationVariable>;
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<any> => {
]);
expect(Object.keys(manifest.sources['src'])).toEqual([
'Account.ts',
'defaultRole.ts',
'hello.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<RoleManifest> => {
const roles: Array<RoleManifest> = [];
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,