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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user