Inject none secret env variables into front components (#20511)

## Summary
- Inject non-secret application variables (`isSecret: false`) into front
component `process.env` via the existing Web Worker `setWorkerEnv`
mechanism
- Filter secret variables server-side in the resolver so they never
reach the browser
- Set application variables before system variables (`TWENTY_API_URL`,
`TWENTY_APP_ACCESS_TOKEN`) to prevent override
- Wire up environment variable keys in the logic function code editor
for TypeScript autocomplete

  ## Test plan
  - [x] Unit tests for `buildNonSecretEnvVar` (6 passing)
  - [x] Typecheck passes for `twenty-front` and `twenty-server`
- [x] Install an app with both `isSecret: false` and `isSecret: true`
variables, open a front component, verify only non-secret vars appear in
`process.env`
- [x] Open a logic function editor, verify autocomplete suggests
declared variable keys
This commit is contained in:
martmull
2026-05-13 18:27:56 +02:00
committed by GitHub
parent 59b993bdb3
commit dea1f89904
25 changed files with 263 additions and 11 deletions
@@ -8,6 +8,7 @@ import {
IsString,
IsUUID,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
@@ -74,4 +75,7 @@ export class FrontComponentDTO {
@Field(() => ApplicationTokenPairDTO, { nullable: true })
applicationTokenPair?: ApplicationTokenPairDTO;
@Field(() => GraphQLJSON, { nullable: true })
applicationVariables?: Record<string, string>;
}
@@ -11,6 +11,7 @@ import { FrontComponentService } from 'src/engine/metadata-modules/front-compone
import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -23,6 +24,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
PermissionsModule,
FlatFrontComponentModule,
SubscriptionsModule,
WorkspaceCacheModule,
],
controllers: [FrontComponentController],
providers: [
@@ -2,6 +2,7 @@ import { Inject, UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -21,6 +22,8 @@ import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/d
import { UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input';
import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service';
import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor';
import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard)
@@ -35,6 +38,7 @@ export class FrontComponentResolver {
private readonly frontComponentService: FrontComponentService,
@Inject(ApplicationTokenService)
private readonly applicationTokenService: ApplicationTokenService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@Query(() => [FrontComponentDTO])
@@ -67,9 +71,31 @@ export class FrontComponentResolver {
userId: user.id,
});
const { applicationVariableMaps } =
await this.workspaceCacheService.getOrRecompute(workspace.id, [
'applicationVariableMaps',
]);
const variableUniversalIdentifiers =
applicationVariableMaps.universalIdentifiersByApplicationId[
dto.applicationId
] ?? [];
const flatApplicationVariables = variableUniversalIdentifiers
.map(
(universalIdentifier) =>
applicationVariableMaps.byUniversalIdentifier[universalIdentifier],
)
.filter(isDefined);
const applicationVariables = stripSecretFromApplicationVariables(
flatApplicationVariables,
);
return {
...dto,
applicationTokenPair: tokenPair,
applicationVariables,
};
}
@@ -0,0 +1,104 @@
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables';
const makeFlatVariable = (
overrides: Partial<FlatApplicationVariable>,
): FlatApplicationVariable => ({
id: '1',
key: 'KEY',
value: 'value',
description: '',
isSecret: false,
applicationId: 'app-1',
workspaceId: '00000000-0000-0000-0000-000000000000',
universalIdentifier: '00000000-0000-0000-0000-000000000000',
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
...overrides,
});
describe('stripSecretFromApplicationVariables', () => {
it('should return empty object for empty array', () => {
expect(stripSecretFromApplicationVariables([])).toEqual({});
});
it('should include non-secret variables', () => {
const variables = [
makeFlatVariable({ key: 'PUBLIC_URL', value: 'https://example.com' }),
makeFlatVariable({ id: '2', key: 'DEBUG', value: 'true' }),
];
expect(stripSecretFromApplicationVariables(variables)).toEqual({
PUBLIC_URL: 'https://example.com',
DEBUG: 'true',
});
});
it('should exclude secret variables', () => {
const variables = [
makeFlatVariable({ key: 'PUBLIC_URL', value: 'https://example.com' }),
makeFlatVariable({
id: '2',
key: 'API_SECRET',
value: 'encrypted_secret',
isSecret: true,
}),
makeFlatVariable({ id: '3', key: 'DEBUG', value: 'true' }),
];
const result = stripSecretFromApplicationVariables(variables);
expect(result).toEqual({
PUBLIC_URL: 'https://example.com',
DEBUG: 'true',
});
expect(result).not.toHaveProperty('API_SECRET');
});
it('should handle null and undefined values', () => {
const variables = [
makeFlatVariable({
key: 'NULL_VALUE',
value: null as unknown as string,
}),
makeFlatVariable({
id: '2',
key: 'UNDEFINED_VALUE',
value: undefined as unknown as string,
}),
];
expect(stripSecretFromApplicationVariables(variables)).toEqual({
NULL_VALUE: '',
UNDEFINED_VALUE: '',
});
});
it('should convert non-string values to strings', () => {
const variables = [
makeFlatVariable({
key: 'NUMBER_VALUE',
value: 123 as unknown as string,
}),
];
expect(stripSecretFromApplicationVariables(variables)).toEqual({
NUMBER_VALUE: '123',
});
});
it('should return empty object when all variables are secret', () => {
const variables = [
makeFlatVariable({ key: 'SECRET_1', value: 'val1', isSecret: true }),
makeFlatVariable({
id: '2',
key: 'SECRET_2',
value: 'val2',
isSecret: true,
}),
];
expect(stripSecretFromApplicationVariables(variables)).toEqual({});
});
});
@@ -0,0 +1,20 @@
import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
export const stripSecretFromApplicationVariables = (
flatApplicationVariables: FlatApplicationVariable[],
): Record<string, string> => {
return flatApplicationVariables.reduce<Record<string, string>>(
(acc, flatApplicationVariable) => {
if (flatApplicationVariable.isSecret) {
return acc;
}
acc[flatApplicationVariable.key] = String(
flatApplicationVariable.value ?? '',
);
return acc;
},
{},
);
};