twenty/packages/twenty-server/patches/@nestjs+graphql+12.1.1.patch
Félix Malfait 75848ff8ea
Some checks are pending
CD deploy main / deploy-main (push) Waiting to run
CI Create App E2E minimal / changed-files-check (push) Waiting to run
CI Create App E2E minimal / create-app-e2e-minimal (push) Blocked by required conditions
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Blocked by required conditions
CI Emails / emails-test (push) Blocked by required conditions
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Blocked by required conditions
CI Example App Postcard / changed-files-check (push) Waiting to run
CI Example App Postcard / example-app-postcard (push) Blocked by required conditions
CI Example App Postcard / ci-example-app-postcard-status-check (push) Blocked by required conditions
Push translations to Crowdin / Extract and upload translations (push) Waiting to run
CI Create App / changed-files-check (push) Waiting to run
CI Create App / create-app-test (lint) (push) Blocked by required conditions
CI Create App / create-app-test (test) (push) Blocked by required conditions
CI Create App / create-app-test (typecheck) (push) Blocked by required conditions
CI Create App / ci-create-app-status-check (push) Blocked by required conditions
CI Docs / changed-files-check (push) Waiting to run
CI Docs / docs-lint (push) Blocked by required conditions
CI Emails / changed-files-check (push) Waiting to run
CI Emails / ci-emails-status-check (push) Blocked by required conditions
CI Example App Hello World / changed-files-check (push) Waiting to run
CI Example App Hello World / example-app-hello-world (push) Blocked by required conditions
feat: move admin panel to dedicated /admin-panel GraphQL endpoint (#19852)
## Summary

Splits admin-panel resolvers off the shared `/metadata` GraphQL endpoint
onto a dedicated `/admin-panel` endpoint. The backend plumbing mirrors
the existing `metadata` / `core` pattern (new scope, decorator, module,
factory), and admin types now live in their own
`generated-admin/graphql.ts` on the frontend — dropping 877 lines of
admin noise from `generated-metadata`.

## Why

- **Smaller attack surface on `/metadata`** — every authenticated user
hits that endpoint; admin ops don't belong there.
- **Independent complexity limits and monitoring** per endpoint.
- **Cleaner module boundaries** — admin is a cross-cutting concern that
doesn't match the "shared-schema configuration" meaning of `/metadata`.
- **Deploy / blast-radius isolation** — a broken admin query can't
affect `/metadata`.

Runtime behavior, auth, and authorization are unchanged — this is a
relocation, not a re-permissioning. All existing guards
(`WorkspaceAuthGuard`, `UserAuthGuard`,
`SettingsPermissionGuard(SECURITY)` at class level; `AdminPanelGuard` /
`ServerLevelImpersonateGuard` at method level) remain on
`AdminPanelResolver`.

## What changed

### Backend
- `@AdminResolver()` decorator with scope `'admin'`, naming parallels
`CoreResolver` / `MetadataResolver`.
- `AdminPanelGraphQLApiModule` + `adminPanelModuleFactory` registered at
`/admin-panel`, same Yoga hook set as the metadata factory (Sentry
tracing, error handler, introspection-disabling in prod, complexity
validation).
- Middleware chain on `/admin-panel` is identical to `/metadata`.
- `@nestjs/graphql` patch extended: `resolverSchemaScope?: 'core' |
'metadata' | 'admin'`.
- `AdminPanelResolver` class decorator swapped from
`@MetadataResolver()` to `@AdminResolver()` — no other changes.

### Frontend
- `codegen-admin.cjs` → `src/generated-admin/graphql.ts` (982 lines).
- `codegen-metadata.cjs` excludes admin paths; metadata file shrinks by
877 lines.
- `ApolloAdminProvider` / `useApolloAdminClient` follow the existing
`ApolloCoreProvider` / `useApolloCoreClient` pattern, wired inside
`AppRouterProviders` alongside the core provider.
- 37 admin consumer files migrated: imports switched to
`~/generated-admin/graphql` and `client: useApolloAdminClient()` is
passed to `useQuery` / `useMutation`.
- Three files intentionally kept on `generated-metadata` because they
consume non-admin Documents: `useHandleImpersonate.ts`,
`SettingsAdminApplicationRegistrationDangerZone.tsx`,
`SettingsAdminApplicationRegistrationGeneralToggles.tsx`.

### CI
- `ci-server.yaml` runs all three `graphql:generate` configurations and
diff-checks all three generated dirs.

## Authorization (unchanged, but audited while reviewing)

Every one of the 38 methods on `AdminPanelResolver` has a method-level
guard:
- `AdminPanelGuard` (32 methods) — requires `canAccessFullAdminPanel ===
true`
- `ServerLevelImpersonateGuard` (6 methods: user/workspace lookup + chat
thread views) — requires `canImpersonate === true`

On top of the class-level guards above. No resolver method is accessible
without these flags + `SECURITY` permission in the workspace.

## Test plan

- [ ] Dev server boots; `/graphql`, `/metadata`, `/admin-panel` all
mapped as separate GraphQL routes (confirmed locally during
development).
- [ ] `nx typecheck twenty-server` passes.
- [ ] `nx typecheck twenty-front` passes.
- [ ] `nx lint:diff-with-main twenty-server` and `twenty-front` both
clean.
- [ ] Manual smoke test: log in with a user who has
`canAccessFullAdminPanel=true`, open the admin panel at
`/settings/admin-panel`, verify each tab loads (General, Health, Config
variables, AI, Apps, Workspace details, User details, chat threads).
- [ ] Manual smoke test: log in with a user who has
`canImpersonate=false` and `canAccessFullAdminPanel=false`, hit
`/admin-panel` directly with a raw GraphQL request, confirm permission
error on every operation.
- [ ] Production deploy note: reverse proxy / ingress must route the new
`/admin-panel` path to the Nest server. If the proxy has an explicit
allowlist, infra change required before cutover.

## Follow-ups (out of scope here)

- Consider cutting over the three
`SettingsAdminApplicationRegistration*` components to admin-scope
versions of the app-registration operations so the admin page is fully
on the admin endpoint.
- The `renderGraphiQL` double-assignment in
`admin-panel.module-factory.ts` is copied from
`metadata.module-factory.ts` — worth cleaning up in both.
2026-04-19 20:55:10 +02:00

316 lines
18 KiB
Diff

diff --git a/dist/interfaces/gql-module-options.interface.d.ts b/dist/interfaces/gql-module-options.interface.d.ts
index 72bab49dcc2b411408c75adf64c3f250cdeaa195..068dc04966e3f7ac7c7093f9ed8f29f24337cebc 100644
--- a/dist/interfaces/gql-module-options.interface.d.ts
+++ b/dist/interfaces/gql-module-options.interface.d.ts
@@ -97,6 +97,10 @@ export interface GqlModuleOptions<TDriver extends GraphQLDriver = any> {
* Extra static metadata to be loaded into the specification
*/
metadata?: () => Promise<Record<string, any>>;
+ /**
+ * When set, only resolvers decorated with a matching scope will be included in this schema.
+ */
+ resolverSchemaScope?: 'core' | 'metadata' | 'admin';
}
export interface GqlOptionsFactory<T extends Record<string, any> = GqlModuleOptions> {
createGqlOptions(): Promise<Omit<T, 'driver'>> | Omit<T, 'driver'>;
diff --git a/dist/schema-builder/graphql-schema.factory.js b/dist/schema-builder/graphql-schema.factory.js
index f349a6e8ea629f8784143b2596e9e95b3255d348..790b062233936dcb336bc615887870d5e7a24de8 100644
--- a/dist/schema-builder/graphql-schema.factory.js
+++ b/dist/schema-builder/graphql-schema.factory.js
@@ -16,6 +16,7 @@ const subscription_type_factory_1 = require("./factories/subscription-type.facto
const lazy_metadata_storage_1 = require("./storages/lazy-metadata.storage");
const type_metadata_storage_1 = require("./storages/type-metadata.storage");
const type_definitions_generator_1 = require("./type-definitions.generator");
+const get_interfaces_array_util_1 = require("./utils/get-interfaces-array.util");
let GraphQLSchemaFactory = GraphQLSchemaFactory_1 = class GraphQLSchemaFactory {
constructor(queryTypeFactory, mutationTypeFactory, subscriptionTypeFactory, orphanedTypesFactory, typeDefinitionsGenerator) {
this.queryTypeFactory = queryTypeFactory;
@@ -32,9 +33,14 @@ let GraphQLSchemaFactory = GraphQLSchemaFactory_1 = class GraphQLSchemaFactory {
else {
options = scalarsOrOptions;
}
+ this.typeDefinitionsGenerator.clearTypeDefinitionStorage();
+ this.orphanedTypesFactory.orphanedReferenceRegistry.clear();
lazy_metadata_storage_1.LazyMetadataStorage.load(resolvers);
type_metadata_storage_1.TypeMetadataStorage.compile(options.orphanedTypes);
- this.typeDefinitionsGenerator.generate(options);
+ const reachableTypes = Array.isArray(resolvers)
+ ? this.computeReachableTypes(resolvers, options.orphanedTypes)
+ : undefined;
+ this.typeDefinitionsGenerator.generate(options, reachableTypes);
const schema = new graphql_1.GraphQLSchema({
mutation: this.mutationTypeFactory.create(resolvers, options),
query: this.queryTypeFactory.create(resolvers, options),
@@ -68,6 +74,116 @@ let GraphQLSchemaFactory = GraphQLSchemaFactory_1 = class GraphQLSchemaFactory {
.forEach((classRef) => this.addScalarTypeByClassRef(classRef, scalarsMap));
options.scalarsMap = scalarsMap;
}
+ computeReachableTypes(resolverClasses, orphanedTypes) {
+ const SCALAR_TYPES = [String, Number, Boolean, Date];
+ const reachableTypes = new Set();
+ const pendingTypes = [];
+ const resolveTypeFn = (typeFn) => {
+ try { return typeFn === null || typeFn === void 0 ? void 0 : typeFn(); } catch (_) { return undefined; }
+ };
+ const markTypeForVisit = (typeReference) => {
+ if (!typeReference || reachableTypes.has(typeReference)) {
+ return;
+ }
+ if (typeof typeReference === 'function' && !SCALAR_TYPES.includes(typeReference)) {
+ pendingTypes.push(typeReference);
+ return;
+ }
+ // Symbols are union type identifiers from createUnionType()
+ if (typeof typeReference === 'symbol') {
+ reachableTypes.add(typeReference);
+ for (const unionMetadata of type_metadata_storage_1.TypeMetadataStorage.getUnionsMetadata()) {
+ if (unionMetadata.id === typeReference) {
+ for (const memberType of resolveTypeFn(unionMetadata.typesFn) || []) {
+ markTypeForVisit(memberType);
+ }
+ }
+ }
+ }
+ };
+ const getTypeMetadataByTarget = (target) => [
+ type_metadata_storage_1.TypeMetadataStorage.getObjectTypeMetadataByTarget(target),
+ type_metadata_storage_1.TypeMetadataStorage.getInputTypeMetadataByTarget(target),
+ type_metadata_storage_1.TypeMetadataStorage.getInterfaceMetadataByTarget(target),
+ type_metadata_storage_1.TypeMetadataStorage.getArgumentsMetadataByTarget(target),
+ ].filter(Boolean);
+ // Seed from resolver query/mutation/subscription handlers
+ const resolverHandlers = [
+ ...type_metadata_storage_1.TypeMetadataStorage.getQueriesMetadata(),
+ ...type_metadata_storage_1.TypeMetadataStorage.getMutationsMetadata(),
+ ...type_metadata_storage_1.TypeMetadataStorage.getSubscriptionsMetadata(),
+ ].filter((handler) => resolverClasses.includes(handler.target));
+ for (const handler of resolverHandlers) {
+ markTypeForVisit(resolveTypeFn(handler.typeFn));
+ for (const argument of handler.methodArgs || []) {
+ markTypeForVisit(resolveTypeFn(argument.typeFn));
+ }
+ }
+ for (const orphanedType of orphanedTypes || []) {
+ markTypeForVisit(orphanedType);
+ }
+ // Walk all fields, arguments, and interfaces from each pending type
+ const visitAllPendingTypes = () => {
+ while (pendingTypes.length > 0) {
+ const currentType = pendingTypes.shift();
+ if (reachableTypes.has(currentType)) {
+ continue;
+ }
+ reachableTypes.add(currentType);
+ // Walk prototype chain to catch inherited metadata
+ // (e.g. PartialType / OmitType dynamic parents)
+ let ancestor = currentType;
+ while (ancestor && ancestor !== Function.prototype && ancestor !== Object) {
+ for (const typeMetadata of getTypeMetadataByTarget(ancestor)) {
+ for (const property of typeMetadata.properties || []) {
+ markTypeForVisit(resolveTypeFn(property.typeFn));
+ for (const argument of property.methodArgs || []) {
+ markTypeForVisit(resolveTypeFn(argument.typeFn));
+ }
+ }
+ for (const interfaceType of (0, get_interfaces_array_util_1.getInterfacesArray)(typeMetadata.interfaces)) {
+ markTypeForVisit(interfaceType);
+ }
+ }
+ ancestor = Object.getPrototypeOf(ancestor);
+ // Ancestors with registered metadata must also be in the
+ // reachable set for field resolution to work
+ if (ancestor && ancestor !== Function.prototype && ancestor !== Object
+ && typeof ancestor === 'function' && !SCALAR_TYPES.includes(ancestor)
+ && !reachableTypes.has(ancestor) && getTypeMetadataByTarget(ancestor).length > 0) {
+ reachableTypes.add(ancestor);
+ }
+ }
+ }
+ };
+ // Pass 1: follow field references from resolver return types and arguments
+ visitAllPendingTypes();
+ // Pass 2: include types that implement a reachable interface
+ // (interfaces don't have back-pointers to their implementors)
+ for (const objectTypeMetadata of type_metadata_storage_1.TypeMetadataStorage.getObjectTypesMetadata()) {
+ if ((0, get_interfaces_array_util_1.getInterfacesArray)(objectTypeMetadata.interfaces)
+ .some((interfaceType) => reachableTypes.has(interfaceType))) {
+ markTypeForVisit(objectTypeMetadata.target);
+ }
+ }
+ visitAllPendingTypes();
+ // Pass 3: include entire unions when any member is reachable
+ // (union membership is stored on the union, not on the member types)
+ for (const unionMetadata of type_metadata_storage_1.TypeMetadataStorage.getUnionsMetadata()) {
+ if (reachableTypes.has(unionMetadata.id)) {
+ continue;
+ }
+ const memberTypes = resolveTypeFn(unionMetadata.typesFn) || [];
+ if (memberTypes.some((memberType) => reachableTypes.has(memberType))) {
+ reachableTypes.add(unionMetadata.id);
+ for (const memberType of memberTypes) {
+ markTypeForVisit(memberType);
+ }
+ }
+ }
+ visitAllPendingTypes();
+ return reachableTypes;
+ }
addScalarTypeByClassRef(classRef, scalarsMap) {
try {
const scalarNameMetadata = Reflect.getMetadata(graphql_constants_1.SCALAR_NAME_METADATA, classRef);
diff --git a/dist/schema-builder/services/orphaned-reference.registry.js b/dist/schema-builder/services/orphaned-reference.registry.js
index f63bf917164e05b6a363d40e871303863800c4f3..101c67ee876a8c9758a3ed35fdb8b656a7a873ba 100644
--- a/dist/schema-builder/services/orphaned-reference.registry.js
+++ b/dist/schema-builder/services/orphaned-reference.registry.js
@@ -21,6 +21,9 @@ let OrphanedReferenceRegistry = class OrphanedReferenceRegistry {
getAll() {
return [...this.registry.values()];
}
+ clear() {
+ this.registry.clear();
+ }
};
exports.OrphanedReferenceRegistry = OrphanedReferenceRegistry;
exports.OrphanedReferenceRegistry = OrphanedReferenceRegistry = tslib_1.__decorate([
diff --git a/dist/schema-builder/storages/type-definitions.storage.js b/dist/schema-builder/storages/type-definitions.storage.js
index a19dee7a15b355a83124479f7e5e6cb91795070d..d28bdc8f03b4e77db93396ff28fb47d8176f0c21 100644
--- a/dist/schema-builder/storages/type-definitions.storage.js
+++ b/dist/schema-builder/storages/type-definitions.storage.js
@@ -81,6 +81,15 @@ let TypeDefinitionsStorage = class TypeDefinitionsStorage {
}
return;
}
+ clear() {
+ // Only invalidate the lazy link caches, NOT the type Maps.
+ // The Maps accumulate types from all schema builds. This is
+ // needed because resolveType closures from earlier schemas
+ // look up types in objectTypeDefinitions at query time.
+ // The add* methods use Map.set() so duplicates are overwritten.
+ this.inputTypeDefinitionsLinks = null;
+ this.outputTypeDefinitionsLinks = null;
+ }
};
exports.TypeDefinitionsStorage = TypeDefinitionsStorage;
exports.TypeDefinitionsStorage = TypeDefinitionsStorage = tslib_1.__decorate([
diff --git a/dist/schema-builder/type-definitions.generator.js b/dist/schema-builder/type-definitions.generator.js
index d5423f1603ea2dbe32f70778cb715de648960cf3..47c80de38dc9045b1ee043dd6f74bb22a66d3b4a 100644
--- a/dist/schema-builder/type-definitions.generator.js
+++ b/dist/schema-builder/type-definitions.generator.js
@@ -19,25 +19,37 @@ let TypeDefinitionsGenerator = class TypeDefinitionsGenerator {
this.interfaceDefinitionFactory = interfaceDefinitionFactory;
this.unionDefinitionFactory = unionDefinitionFactory;
}
- generate(options) {
- this.generateUnionDefs();
+ generate(options, reachableTypes) {
+ this.generateUnionDefs(reachableTypes);
this.generateEnumDefs();
- this.generateInterfaceDefs(options);
- this.generateObjectTypeDefs(options);
- this.generateInputTypeDefs(options);
+ this.generateInterfaceDefs(options, reachableTypes);
+ this.generateObjectTypeDefs(options, reachableTypes);
+ this.generateInputTypeDefs(options, reachableTypes);
}
- generateInputTypeDefs(options) {
- const metadata = type_metadata_storage_1.TypeMetadataStorage.getInputTypesMetadata();
+ clearTypeDefinitionStorage() {
+ this.typeDefinitionsStorage.clear();
+ }
+ generateInputTypeDefs(options, reachableTypes) {
+ let metadata = type_metadata_storage_1.TypeMetadataStorage.getInputTypesMetadata();
+ if (reachableTypes) {
+ metadata = metadata.filter((inputMetadata) => reachableTypes.has(inputMetadata.target));
+ }
const inputTypeDefs = metadata.map((metadata) => this.inputTypeDefinitionFactory.create(metadata, options));
this.typeDefinitionsStorage.addInputTypes(inputTypeDefs);
}
- generateObjectTypeDefs(options) {
- const metadata = type_metadata_storage_1.TypeMetadataStorage.getObjectTypesMetadata();
+ generateObjectTypeDefs(options, reachableTypes) {
+ let metadata = type_metadata_storage_1.TypeMetadataStorage.getObjectTypesMetadata();
+ if (reachableTypes) {
+ metadata = metadata.filter((objectMetadata) => reachableTypes.has(objectMetadata.target));
+ }
const objectTypeDefs = metadata.map((metadata) => this.objectTypeDefinitionFactory.create(metadata, options));
this.typeDefinitionsStorage.addObjectTypes(objectTypeDefs);
}
- generateInterfaceDefs(options) {
- const metadata = type_metadata_storage_1.TypeMetadataStorage.getInterfacesMetadata();
+ generateInterfaceDefs(options, reachableTypes) {
+ let metadata = type_metadata_storage_1.TypeMetadataStorage.getInterfacesMetadata();
+ if (reachableTypes) {
+ metadata = metadata.filter((interfaceMetadata) => reachableTypes.has(interfaceMetadata.target));
+ }
const interfaceDefs = metadata.map((metadata) => this.interfaceDefinitionFactory.create(metadata, options));
this.typeDefinitionsStorage.addInterfaces(interfaceDefs);
}
@@ -46,8 +58,22 @@ let TypeDefinitionsGenerator = class TypeDefinitionsGenerator {
const enumDefs = metadata.map((metadata) => this.enumDefinitionFactory.create(metadata));
this.typeDefinitionsStorage.addEnums(enumDefs);
}
- generateUnionDefs() {
- const metadata = type_metadata_storage_1.TypeMetadataStorage.getUnionsMetadata();
+ generateUnionDefs(reachableTypes) {
+ let metadata = type_metadata_storage_1.TypeMetadataStorage.getUnionsMetadata();
+ if (reachableTypes) {
+ metadata = metadata.filter((unionMetadata) => {
+ if (reachableTypes.has(unionMetadata.id)) {
+ return true;
+ }
+ try {
+ const memberTypes = unionMetadata.typesFn();
+ return memberTypes.some((memberType) => reachableTypes.has(memberType));
+ }
+ catch (_) {
+ return true;
+ }
+ });
+ }
const unionDefs = metadata.map((metadata) => this.unionDefinitionFactory.create(metadata));
this.typeDefinitionsStorage.addUnions(unionDefs);
}
diff --git a/dist/services/resolvers-explorer.service.js b/dist/services/resolvers-explorer.service.js
index 1cc43269cd0c4fbe446789b38970118f0c1856c7..3eeb77fcd3c78a134896faa3424209abc47b971a 100644
--- a/dist/services/resolvers-explorer.service.js
+++ b/dist/services/resolvers-explorer.service.js
@@ -21,6 +21,7 @@ const graphql_constants_1 = require("../graphql.constants");
const decorate_field_resolver_util_1 = require("../utils/decorate-field-resolver.util");
const extract_metadata_util_1 = require("../utils/extract-metadata.util");
const base_explorer_service_1 = require("./base-explorer.service");
+const RESOLVER_SCHEMA_SCOPE_KEY = 'RESOLVER_SCHEMA_SCOPE';
let ResolversExplorerService = ResolversExplorerService_1 = class ResolversExplorerService extends base_explorer_service_1.BaseExplorerService {
constructor(modulesContainer, metadataScanner, externalContextCreator, gqlOptions, moduleRef, serializedGraph) {
super();
@@ -45,6 +46,15 @@ let ResolversExplorerService = ResolversExplorerService_1 = class ResolversExplo
if (!instance) {
return undefined;
}
+ const resolverSchemaScope = this.gqlOptions.resolverSchemaScope;
+ if (resolverSchemaScope && wrapper.metatype) {
+ // Default to 'metadata' for unscoped resolvers (e.g. nestjs-query auto-generated CRUD)
+ // TODO: remove this fallback once nestjs-query is deprecated
+ const resolverScope = Reflect.getMetadata(RESOLVER_SCHEMA_SCOPE_KEY, wrapper.metatype) || 'metadata';
+ if (resolverScope !== resolverSchemaScope) {
+ return undefined;
+ }
+ }
const prototype = Object.getPrototypeOf(instance);
const predicate = (resolverType, isReferenceResolver, isPropertyResolver) => (0, shared_utils_1.isUndefined)(resolverType) ||
(!isReferenceResolver &&
@@ -147,6 +157,15 @@ let ResolversExplorerService = ResolversExplorerService_1 = class ResolversExplo
getAllCtors() {
const modules = this.getModules(this.modulesContainer, this.gqlOptions.include || []);
const resolvers = this.flatMap(modules, this.mapToCtor).filter(Boolean);
+ const resolverSchemaScope = this.gqlOptions.resolverSchemaScope;
+ if (resolverSchemaScope) {
+ return resolvers.filter((resolverConstructor) => {
+ // Default to 'metadata' for unscoped resolvers (e.g. nestjs-query auto-generated CRUD)
+ // TODO: remove this fallback once nestjs-query is deprecated
+ const resolverScope = Reflect.getMetadata(RESOLVER_SCHEMA_SCOPE_KEY, resolverConstructor) || 'metadata';
+ return resolverScope === resolverSchemaScope;
+ });
+ }
return resolvers;
}
mapToCtor(wrapper) {