Proxy API routes through the vite dev server to keep local dev same-origin (#23779)

Replaces #23774 (closed), rebased on latest main.

## Problem

Since the cookie-session migration (#23642), the front sends every
request with `credentials: 'include'` and the server only reflects
`Access-Control-Allow-Origin` for the exact origins in the credentialed
allowlist (`SERVER_URL`, `FRONTEND_URL`, `AUTH_COOKIE_ALLOWED_ORIGINS`).
Any other origin gets the `*` wildcard, which browsers reject for
credentialed requests.

Local dev is split-origin by default (front on `localhost:3001`, API on
`localhost:3000`), and with `IS_MULTIWORKSPACE_ENABLED` every workspace
subdomain (`apple.localhost:3001`, ...) is yet another origin. Each
locally created workspace would need a manual
`AUTH_COOKIE_ALLOWED_ORIGINS` entry.

## Solution

Make local dev same-origin instead of widening the CORS policy: the vite
dev server now proxies all top-level API route prefixes to the backend,
and the front calls its own origin.

- `vite.config.ts` adds a `server.proxy` covering the backend's
top-level prefixes (`/graphql`, `/metadata`, `/admin-panel`, `/auth`,
`/rest`, `/file`, `/client-config`, ...), defined in
`src/config/apiProxyPrefixes.ts`. Keys are anchored regexes
(`^/auth($|[/?])`) so SPA routes sharing a prefix (`/authorize`,
`/settings`) are not swallowed. The target defaults to
`http://localhost:3000` and follows `REACT_APP_SERVER_BASE_URL`.
`changeOrigin` stays off so the backend sees the browser's Host:
same-origin checks (CSRF, cookie issuance) and workspace resolution by
subdomain work unchanged through the proxy.
- `config/index.ts` collapses to
`window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`.
Every supported production path injects `window._env_` (docker
entrypoint fails hard without `REACT_APP_SERVER_BASE_URL`; a
server-served front gets it from `generateFrontConfig()`), and in dev
the current origin is correct on `localhost:3001` and every
`*.localhost:3001` workspace subdomain thanks to the proxy. The removed
`http://<hostname>:3000` fallback only served an un-injected production
bundle browsed on localhost, a setup whose credentialed auth the
cookie-session migration had already broken.

The credentialed allowlist itself is unchanged and stays strict; since
dev traffic is same-origin, the per-subdomain cookie-allowlist problem
disappears without loosening any production CORS/CSRF policy.

## Tests

- `src/config/__tests__/apiProxyPrefixes.test.ts` guards the proxy
boundary in both directions: representative backend path shapes
(including `/metadata?query=...` and `/auth/...`) must match, every SPA
route from the `AppPath` enum and vite's own dev paths must not — so a
future route collision fails unit tests instead of breaking dev.
- Verified against running dev servers: API paths proxy to the backend
from both `localhost:3001` and `apple.localhost:3001`, while SPA routes
`/settings` and `/authorize` still serve the vite app; a same-origin
POST from `apple.localhost:3001` goes through with no CORS involvement.
- `lint:diff-with-main` and `typecheck` pass for twenty-front.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Paul Rastoin
2026-08-05 10:08:51 +02:00
committed by GitHub
parent 8a856a4bce
commit 8bfa9c4adb
68 changed files with 312 additions and 136 deletions
@@ -0,0 +1,43 @@
import { ApiPath, AppPath } from 'twenty-shared/types';
import {
API_PROXY_PATHS,
buildApiProxyMatcher,
} from '~/config/apiProxyPrefixes';
const matchers = API_PROXY_PATHS.map(
(apiPath) => new RegExp(buildApiProxyMatcher(apiPath)),
);
const isProxiedPath = (path: string) =>
matchers.some((matcher) => matcher.test(path));
describe('apiProxyPrefixes', () => {
it.each(Object.values(ApiPath))('should proxy the API path %s', (apiPath) => {
expect(isProxiedPath(`/${apiPath}`)).toBe(true);
expect(isProxiedPath(`/${apiPath}/nested-path`)).toBe(true);
expect(isProxiedPath(`/${apiPath}?query=value`)).toBe(true);
});
it.each(Object.values(AppPath))(
'should not proxy the SPA route %s',
(appPath) => {
expect(isProxiedPath(appPath)).toBe(false);
},
);
const viteDevServerPaths = [
'/',
'/index.html',
'/src/main.tsx',
'/@vite/client',
'/node_modules/.vite/deps/react.js',
];
it.each(viteDevServerPaths)(
'should not proxy the vite dev server path %s',
(viteDevServerPath) => {
expect(isProxiedPath(viteDevServerPath)).toBe(false);
},
);
});
@@ -0,0 +1,9 @@
import { ApiPath } from 'twenty-shared/types';
export const API_PROXY_PATHS = Object.values(ApiPath);
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const buildApiProxyMatcher = (apiPath: ApiPath) =>
`^/${escapeRegExp(apiPath)}($|[/?])`;
+1 -20
View File
@@ -1,21 +1,2 @@
const getDefaultUrl = () => {
if (
window.location.hostname.endsWith('localhost') ||
window.location.hostname.endsWith('127.0.0.1')
) {
// In development environment front and backend usually run on separate ports
// we set the default value to localhost:3000.
// In dev context, we use env vars to overwrite it
return `http://${window.location.hostname}:3000`;
} else {
// Outside of localhost we assume that they run on the same port
// because the backend will serve the frontend
// In prod context, we use index.html + window var to ovewrite it
return `${window.location.protocol}//${window.location.hostname}${
window.location.port ? `:${window.location.port}` : ''
}`;
}
};
export const REACT_APP_SERVER_BASE_URL =
window._env_?.REACT_APP_SERVER_BASE_URL || getDefaultUrl();
window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin;
+26 -2
View File
@@ -15,6 +15,11 @@ import svgr from 'vite-plugin-svgr';
import { createWywProfilingPlugin } from 'twenty-shared/vite';
import {
API_PROXY_PATHS,
buildApiProxyMatcher,
} from './src/config/apiProxyPrefixes';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, '');
@@ -24,6 +29,7 @@ export default defineConfig(({ mode }) => {
SSL_CERT_PATH,
SSL_KEY_PATH,
REACT_APP_PORT,
REACT_APP_SERVER_BASE_URL,
IS_DEBUG_MODE,
} = env;
@@ -31,6 +37,17 @@ export default defineConfig(({ mode }) => {
? parseInt(REACT_APP_PORT)
: 3001;
const apiProxyTarget = isNonEmptyString(REACT_APP_SERVER_BASE_URL)
? REACT_APP_SERVER_BASE_URL
: 'http://localhost:3000';
const apiProxy = Object.fromEntries(
API_PROXY_PATHS.map((apiPath) => [
buildApiProxyMatcher(apiPath),
{ target: apiProxyTarget },
]),
);
const CHUNK_SIZE_WARNING_LIMIT = 1024 * 1024; // 1MB
// Please don't increase this limit for main index chunk
// If it gets too big then find modules in the code base
@@ -49,6 +66,7 @@ export default defineConfig(({ mode }) => {
server: {
port: port,
proxy: apiProxy,
...(VITE_HOST ? { host: VITE_HOST } : {}),
...(SSL_KEY_PATH && SSL_CERT_PATH
? {
@@ -260,9 +278,15 @@ export default defineConfig(({ mode }) => {
// wyw-in-js 1.x resolves modules in its CSS evaluator via vite's
// resolve.alias (not resolve.tsconfigPaths), so the `@/` and `~/`
// tsconfig path aliases must be mirrored here.
{ find: /^@\//, replacement: path.resolve(__dirname, 'src/modules') + '/' },
{
find: /^@\//,
replacement: path.resolve(__dirname, 'src/modules') + '/',
},
{ find: /^~\//, replacement: path.resolve(__dirname, 'src') + '/' },
{ find: 'path', replacement: 'rollup-plugin-node-polyfills/polyfills/path' },
{
find: 'path',
replacement: 'rollup-plugin-node-polyfills/polyfills/path',
},
],
},
};
+7 -6
View File
@@ -13,6 +13,7 @@ import { join } from 'path';
import { YogaDriver, type YogaDriverConfig } from '@graphql-yoga/nestjs';
import { SentryModule } from '@sentry/nestjs/setup';
import { ApiPath } from 'twenty-shared/types';
import { AdminPanelGraphQLApiModule } from 'src/engine/api/graphql/admin-panel-graphql-api.module';
import { CoreGraphQLApiModule } from 'src/engine/api/graphql/core-graphql-api.module';
@@ -131,7 +132,7 @@ export class AppModule {
// A cross-origin form post from the identity provider, authenticated on the
// assertion rather than the cookie.
.exclude({
path: 'auth/saml/callback/:identityProviderId',
path: `${ApiPath.Auth}/saml/callback/:identityProviderId`,
method: RequestMethod.POST,
})
.forRoutes({ path: '*path', method: RequestMethod.ALL });
@@ -141,30 +142,30 @@ export class AppModule {
GraphQLHydrateRequestFromTokenMiddleware,
WorkspaceAuthContextMiddleware,
)
.forRoutes({ path: 'graphql', method: RequestMethod.ALL });
.forRoutes({ path: ApiPath.GraphQL, method: RequestMethod.ALL });
consumer
.apply(
GraphQLHydrateRequestFromTokenMiddleware,
WorkspaceAuthContextMiddleware,
)
.forRoutes({ path: 'metadata', method: RequestMethod.ALL });
.forRoutes({ path: ApiPath.Metadata, method: RequestMethod.ALL });
consumer
.apply(
GraphQLHydrateRequestFromTokenMiddleware,
WorkspaceAuthContextMiddleware,
)
.forRoutes({ path: 'admin-panel', method: RequestMethod.ALL });
.forRoutes({ path: ApiPath.AdminPanel, method: RequestMethod.ALL });
consumer
.apply(McpMethodGuardMiddleware)
.forRoutes({ path: 'mcp', method: RequestMethod.ALL });
.forRoutes({ path: ApiPath.Mcp, method: RequestMethod.ALL });
for (const method of MIGRATED_REST_METHODS) {
consumer
.apply(RestCoreMiddleware, WorkspaceAuthContextMiddleware)
.forRoutes({ path: 'rest/*path', method });
.forRoutes({ path: `${ApiPath.Rest}/*path`, method });
}
}
}
@@ -1,6 +1,7 @@
import { type YogaDriverConfig } from '@graphql-yoga/nestjs';
import * as Sentry from '@sentry/node';
import GraphQLJSON from 'graphql-type-json';
import { ApiPath } from 'twenty-shared/types';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
@@ -29,7 +30,7 @@ export const adminPanelModuleFactory = async (
resolverSchemaScope: 'admin',
buildSchemaOptions: {},
renderGraphiQL() {
return renderApolloPlayground({ path: 'admin-panel' });
return renderApolloPlayground({ path: ApiPath.AdminPanel });
},
resolvers: { JSON: GraphQLJSON },
plugins: [
@@ -50,7 +51,7 @@ export const adminPanelModuleFactory = async (
checkDuplicateRootResolvers: true,
}),
],
path: '/admin-panel',
path: `/${ApiPath.AdminPanel}`,
context: () => ({
loaders: dataloaderService.createLoaders(),
}),
@@ -58,7 +59,7 @@ export const adminPanelModuleFactory = async (
if (twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT) {
config.renderGraphiQL = () => {
return renderApolloPlayground({ path: 'admin-panel' });
return renderApolloPlayground({ path: ApiPath.AdminPanel });
};
}
@@ -8,6 +8,7 @@ import {
} from '@graphql-yoga/nestjs';
import * as Sentry from '@sentry/node';
import GraphQLJSON from 'graphql-type-json';
import { ApiPath } from 'twenty-shared/types';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
@@ -86,6 +87,7 @@ export class GraphQLConfigService implements GqlOptionsFactory<
buildSchemaOptions: {},
resolvers: { JSON: GraphQLJSON },
plugins: plugins,
path: `/${ApiPath.GraphQL}`,
context: () => ({
loaders: this.dataloaderService.createLoaders(),
}),
@@ -1,6 +1,7 @@
import { type YogaDriverConfig } from '@graphql-yoga/nestjs';
import * as Sentry from '@sentry/node';
import GraphQLJSON from 'graphql-type-json';
import { ApiPath } from 'twenty-shared/types';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
@@ -40,7 +41,7 @@ export const metadataModuleFactory = async (
orphanedTypes: [ClientConfig],
},
renderGraphiQL() {
return renderApolloPlayground({ path: 'metadata' });
return renderApolloPlayground({ path: ApiPath.Metadata });
},
resolvers: { JSON: GraphQLJSON },
plugins: [
@@ -70,7 +71,7 @@ export const metadataModuleFactory = async (
checkDuplicateRootResolvers: true,
}),
],
path: '/metadata',
path: `/${ApiPath.Metadata}`,
context: () => ({
loaders: dataloaderService.createLoaders(),
}),
@@ -78,7 +79,7 @@ export const metadataModuleFactory = async (
if (twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT) {
config.renderGraphiQL = () => {
return renderApolloPlayground({ path: 'metadata' });
return renderApolloPlayground({ path: ApiPath.Metadata });
};
}
@@ -13,6 +13,7 @@ import {
} from '@nestjs/common';
import { type Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
@@ -30,7 +31,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('mcp')
@Controller(ApiPath.Mcp)
@UseGuards(McpAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UseFilters(RestApiExceptionFilter)
export class McpCoreController {
@@ -13,6 +13,7 @@ import {
} from '@nestjs/common';
import { Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { RestApiCoreService } from 'src/engine/api/rest/core/services/rest-api-core.service';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
@@ -21,7 +22,7 @@ import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('rest')
@Controller(ApiPath.Rest)
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, CustomPermissionGuard)
@UseFilters(RestApiExceptionFilter)
export class RestApiCoreController {
@@ -1,14 +1,15 @@
import { BadRequestException } from '@nestjs/common';
import { type Request } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
export const parseCorePath = (
request: Request,
): { object: string; id?: string } => {
const queryAction = request.path
.replace('/rest/', '')
.replace('/rest', '')
.replace(`/${ApiPath.Rest}/`, '')
.replace(`/${ApiPath.Rest}`, '')
.split('/')
.filter(Boolean);
@@ -17,13 +18,13 @@ export const parseCorePath = (
(queryAction.length > 3 && queryAction[0] === 'restore')
) {
throw new BadRequestException(
`Query path '${request.path}' invalid. Valid examples: /rest/companies/id or /rest/companies or /rest/batch/companies`,
`Query path '${request.path}' invalid. Valid examples: /${ApiPath.Rest}/companies/id or /${ApiPath.Rest}/companies or /${ApiPath.Rest}/batch/companies`,
);
}
if (queryAction.length === 0) {
throw new BadRequestException(
`Query path '${request.path}' invalid. Valid examples: /rest/companies/id or /rest/companies or /rest/batch/companies`,
`Query path '${request.path}' invalid. Valid examples: /${ApiPath.Rest}/companies/id or /${ApiPath.Rest}/companies or /${ApiPath.Rest}/batch/companies`,
);
}
@@ -12,6 +12,7 @@ import {
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApiPath } from 'twenty-shared/types';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
@@ -30,7 +31,7 @@ import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/p
* rest/apiKeys is deprecated, use rest/metadata/apiKeys instead
* rest/apiKeys will be removed in the future
*/
@Controller(['rest/apiKeys', 'rest/metadata/apiKeys'])
@Controller([`${ApiPath.Rest}/apiKeys`, `${ApiPath.Rest}/metadata/apiKeys`])
@UseGuards(
JwtAuthGuard,
WorkspaceAuthGuard,
@@ -1,6 +1,7 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { type Request } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
@@ -12,7 +13,7 @@ import { cleanServerUrl } from 'src/utils/clean-server-url';
import { getRequestBaseUrl } from 'src/utils/get-request-base-url.util';
import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant';
@Controller('.well-known')
@Controller(ApiPath.WellKnown)
export class OAuthDiscoveryController {
constructor(
private readonly twentyConfigService: TwentyConfigService,
@@ -40,10 +41,10 @@ export class OAuthDiscoveryController {
return {
issuer,
authorization_endpoint: `${authorizeBase}/authorize`,
token_endpoint: `${issuer}/oauth/token`,
registration_endpoint: `${issuer}/oauth/register`,
revocation_endpoint: `${issuer}/oauth/revoke`,
introspection_endpoint: `${issuer}/oauth/introspect`,
token_endpoint: `${issuer}/${ApiPath.OAuth}/token`,
registration_endpoint: `${issuer}/${ApiPath.OAuth}/register`,
revocation_endpoint: `${issuer}/${ApiPath.OAuth}/revoke`,
introspection_endpoint: `${issuer}/${ApiPath.OAuth}/introspect`,
scopes_supported: ALL_OAUTH_SCOPES,
response_types_supported: ['code'],
response_modes_supported: ['query'],
@@ -14,6 +14,7 @@ import {
} from '@nestjs/common';
import { type Request, type Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { InjectRepository } from '@nestjs/typeorm';
@@ -41,7 +42,7 @@ const REGISTRATION_RATE_LIMIT_WINDOW_MS = 3_600_000;
const ALLOWED_GRANT_TYPES = ['authorization_code', 'refresh_token'];
const ALLOWED_RESPONSE_TYPES = ['code'];
@Controller('oauth')
@Controller(ApiPath.OAuth)
@UseFilters(AuthRestApiExceptionFilter)
export class OAuthRegistrationController {
constructor(
@@ -174,7 +175,7 @@ export class OAuthRegistrationController {
token_endpoint_auth_method: tokenEndpointAuthMethod,
scope: requestedScopes.join(' '),
client_id_issued_at: Math.floor(Date.now() / 1000),
registration_client_uri: `${issuer}/oauth/register/${clientId}`,
registration_client_uri: `${issuer}/${ApiPath.OAuth}/register/${clientId}`,
};
}
@@ -216,7 +217,7 @@ export class OAuthRegistrationController {
response_types: ['code'],
token_endpoint_auth_method: 'none',
scope: registration.oAuthScopes.join(' '),
registration_client_uri: `${issuer}/oauth/register/${registration.oAuthClientId}`,
registration_client_uri: `${issuer}/${ApiPath.OAuth}/register/${registration.oAuthClientId}`,
};
}
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import { type Request, type Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { OAuthIntrospectInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-introspect.input';
import { OAuthRevokeInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-revoke.input';
@@ -28,7 +29,7 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
const OAUTH_RATE_LIMIT_MAX = 60;
const OAUTH_RATE_LIMIT_WINDOW_MS = 60_000;
@Controller('oauth')
@Controller(ApiPath.OAuth)
@UseFilters(AuthRestApiExceptionFilter)
export class OAuthTokenController {
constructor(
@@ -1,7 +1,7 @@
import { Controller, Get, Query, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { SettingsPath } from 'twenty-shared/types';
import { ApiPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { ApplicationRegistrationClaimService } from 'src/engine/core-modules/application/application-registration/application-registration-claim.service';
@@ -20,7 +20,7 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
// GitHub OAuth callback of the trusted-publishers claim flow. Auth context
// travels in the signed state token, not in a session, hence the public
// endpoint.
@Controller('application-registration-claim')
@Controller(ApiPath.ApplicationRegistrationClaim)
export class ApplicationRegistrationClaimController {
constructor(
private readonly applicationRegistrationClaimService: ApplicationRegistrationClaimService,
@@ -1,6 +1,7 @@
import { UseGuards } from '@nestjs/common';
import { Args, Parent, Query, ResolveField } from '@nestjs/graphql';
import { ApiPath } from 'twenty-shared/types';
import { isAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
@@ -80,6 +81,6 @@ export class ApplicationResolver {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return `${serverUrl}/public-assets/${workspace.id}/${application.id}/${logo}`;
return `${serverUrl}/${ApiPath.PublicAssets}/${workspace.id}/${application.id}/${logo}`;
}
}
@@ -2,7 +2,7 @@ import { Controller, Get, Logger, Query, Res, UseGuards } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type Response } from 'express';
import { SettingsPath } from 'twenty-shared/types';
import { ApiPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -23,7 +23,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth/apps')
@Controller(`${ApiPath.Auth}/apps`)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
export class ConnectionProviderOAuthController {
private readonly logger = new Logger(ConnectionProviderOAuthController.name);
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import { Request } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type AppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.dto';
@@ -26,7 +27,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
* queries on the metadata schema (ApplicationConnectionsResolver). The SDK
* helpers (`listConnections`, `getConnection`) now call GraphQL. Kept for
* backward compatibility with already-deployed app runtimes. */
@Controller('apps/connections')
@Controller(`${ApiPath.Apps}/connections`)
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
export class ApplicationConnectionsController {
@@ -1,5 +1,7 @@
import { ApiPath } from 'twenty-shared/types';
// Workspace-agnostic by design: the workspace identity travels in the
// signed `state` parameter, so a single redirect URL configured at the
// OAuth provider serves every workspace.
export const buildAppOAuthCallbackUrl = (serverUrl: string): string =>
new URL('/auth/apps/callback', serverUrl).toString();
new URL(`/${ApiPath.Auth}/apps/callback`, serverUrl).toString();
@@ -9,7 +9,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { SettingsPath } from 'twenty-shared/types';
import { ApiPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -31,7 +31,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth/google-apis')
@Controller(`${ApiPath.Auth}/google-apis`)
@UseFilters(AuthRestApiExceptionFilter)
export class GoogleAPIsAuthController {
constructor(
@@ -8,6 +8,7 @@ import {
} from '@nestjs/common';
import { Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
@@ -19,7 +20,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth/google')
@Controller(`${ApiPath.Auth}/google`)
@UseFilters(AuthRestApiExceptionFilter)
export class GoogleAuthController {
constructor(private readonly authService: AuthService) {}
@@ -9,7 +9,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { ApiPath, AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -31,7 +31,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth/microsoft-apis')
@Controller(`${ApiPath.Auth}/microsoft-apis`)
@UseFilters(AuthRestApiExceptionFilter)
export class MicrosoftAPIsAuthController {
constructor(
@@ -8,6 +8,7 @@ import {
} from '@nestjs/common';
import { Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
@@ -18,7 +19,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth/microsoft')
@Controller(`${ApiPath.Auth}/microsoft`)
@UseFilters(AuthRestApiExceptionFilter)
export class MicrosoftAuthController {
constructor(private readonly authService: AuthService) {}
@@ -10,6 +10,7 @@ import {
} from '@nestjs/common';
import { Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
@@ -21,7 +22,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth/oauth-propagator')
@Controller(`${ApiPath.Auth}/oauth-propagator`)
@UseFilters(AuthRestApiExceptionFilter)
export class OAuthPropagatorController {
constructor(
@@ -13,7 +13,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import { generateServiceProviderMetadata } from '@node-saml/node-saml';
import { Response } from 'express';
import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
import {
ApiPath,
AppPath,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -43,7 +47,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('auth')
@Controller(ApiPath.Auth)
@UseFilters(AuthRestApiExceptionFilter)
export class SSOAuthController {
constructor(
@@ -3,6 +3,7 @@ import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { type Request } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { parseJson } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -82,7 +83,7 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
return false;
}
const url = new URL('/auth/microsoft', 'http://localhost');
const url = new URL(`/${ApiPath.Auth}/microsoft`, 'http://localhost');
url.searchParams.set('oauthRetryCount', String(oauthRetryCount + 1));
@@ -14,6 +14,7 @@ import {
import { type Response } from 'express';
import Stripe from 'stripe';
import { ApiPath } from 'twenty-shared/types';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
@@ -50,7 +51,7 @@ export class BillingWebhookController {
private readonly billingWebhookSubscriptionScheduleService: BillingWebhookSubscriptionScheduleService,
) {}
@Post(['webhooks/stripe'])
@Post(`${ApiPath.Webhooks}/stripe`)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async handleWebhooks(
@Headers('stripe-signature') signature: string,
@@ -15,6 +15,7 @@ import {
} from '@nestjs/common';
import { Request } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AppBillingService } from 'src/engine/core-modules/billing/app-billing/app-billing.service';
@@ -30,7 +31,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
const APP_BILLING_CHARGE_THROTTLE_LIMIT = 1000;
const APP_BILLING_CHARGE_THROTTLE_TTL_MS = 60_000;
@Controller('app/billing')
@Controller(`${ApiPath.App}/billing`)
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
export class AppBillingController {
constructor(
@@ -1,11 +1,13 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiPath } from 'twenty-shared/types';
import { type ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('/client-config')
@Controller(ApiPath.ClientConfig)
export class ClientConfigController {
constructor(private readonly clientConfigService: ClientConfigService) {}
@@ -3,6 +3,7 @@
import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard';
@@ -20,7 +21,10 @@ export class DnsCloudflareController {
private readonly twentyConfigService: TwentyConfigService,
) {}
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@Post([
`${ApiPath.Cloudflare}/custom-hostname-webhooks`,
`${ApiPath.Webhooks}/cloudflare`,
])
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard, NoPermissionGuard)
async customHostnameWebhooks(@Req() req: Request) {
const hostname = req.body?.data?.data?.hostname;
@@ -14,7 +14,7 @@ import { join } from 'path';
import { type Readable } from 'stream';
import { Request, Response } from 'express';
import { FileFolder, ServerFileFolder } from 'twenty-shared/types';
import { ApiPath, FileFolder, ServerFileFolder } from 'twenty-shared/types';
import {
FileStorageException,
@@ -51,7 +51,9 @@ export class FileController {
// public folder path. These are instance-global marketplace resources, also
// displayed on the public OAuth authorize page, hence no auth token, unlike
// the workspace-scoped /file/:folder/:id.
@Get('files/application-registrations/:applicationRegistrationId/*path')
@Get(
`${ApiPath.Files}/application-registrations/:applicationRegistrationId/*path`,
)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getApplicationRegistrationAsset(
@Res() res: Response,
@@ -110,7 +112,7 @@ export class FileController {
}
}
@Get('public-assets/:workspaceId/:applicationId/*path')
@Get(`${ApiPath.PublicAssets}/:workspaceId/:applicationId/*path`)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getPublicAssets(
@Res() res: Response,
@@ -183,7 +185,7 @@ export class FileController {
}
}
@Get('file/:fileFolder/:id')
@Get(`${ApiPath.File}/:fileFolder/:id`)
@UseGuards(FileByIdGuard, NoPermissionGuard)
async getFileById(
@Res() res: Response,
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common';
import { Request, Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { FileUploadApiExceptionFilter } from 'src/engine/core-modules/file/file-upload/filters/file-upload-api-exception.filter';
import { FileUploadTokenGuard } from 'src/engine/core-modules/file/file-upload/guards/file-upload-token.guard';
@@ -23,7 +24,7 @@ export class FileUploadController {
// Streaming target for direct uploads when storage has no presigned upload
// support (local driver, or S3 without presign enabled). The body is piped
// to the storage driver without ever being buffered in memory.
@Put('file-upload/:id')
@Put(`${ApiPath.FileUpload}/:id`)
@UseGuards(FileUploadTokenGuard, NoPermissionGuard)
async uploadFileById(
@Req() req: Request,
@@ -7,7 +7,7 @@ import { pipeline } from 'stream/promises';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import bytes from 'bytes';
import { FileFolder } from 'twenty-shared/types';
import { ApiPath, FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
@@ -173,7 +173,7 @@ export class FileUploadService {
return {
fileId,
uploadUrl: `${serverUrl}/file-upload/${fileId}?token=${token}`,
uploadUrl: `${serverUrl}/${ApiPath.FileUpload}/${fileId}?token=${token}`,
// octet-stream keeps the request body away from the server's json/text
// body parsers; the real mime type is already on the file record.
contentType: 'application/octet-stream',
@@ -1,10 +1,11 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
import { ApiPath } from 'twenty-shared/types';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('healthz')
@Controller(ApiPath.Health)
export class HealthController {
constructor(private readonly health: HealthCheckService) {}
@@ -1,6 +1,7 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -10,7 +11,7 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
export class OpenApiController {
constructor(private readonly openApiService: OpenApiService) {}
@Get(['open-api/core', 'rest/open-api/core'])
@Get([`${ApiPath.OpenApi}/core`, `${ApiPath.Rest}/open-api/core`])
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async generateOpenApiSchemaCore(
@Req() request: Request,
@@ -21,7 +22,7 @@ export class OpenApiController {
res.send(data);
}
@Get(['open-api/metadata', 'rest/open-api/metadata'])
@Get([`${ApiPath.OpenApi}/metadata`, `${ApiPath.Rest}/open-api/metadata`])
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async generateOpenApiSchemaMetaData(
@Req() request: Request,
@@ -1,4 +1,5 @@
import { type OpenAPIV3_1 } from 'openapi-types';
import { ApiPath } from 'twenty-shared/types';
import { computeOpenApiPath } from 'src/engine/core-modules/open-api/utils/path.utils';
@@ -121,7 +122,7 @@ hand the file to your tool — never paste a tokenized URL into a chat:
\`\`\`bash
curl -H 'Authorization: Bearer <token>' \\
${serverUrl}/rest/open-api/${schemaName} > twenty-${schemaName}.json
${serverUrl}/${ApiPath.Rest}/open-api/${schemaName} > twenty-${schemaName}.json
\`\`\`
`,
termsOfService:
@@ -138,7 +139,7 @@ curl -H 'Authorization: Bearer <token>' \\
// Testing purposes
servers: [
{
url: `${serverUrl}/rest/${schemaName !== 'core' ? schemaName : ''}`,
url: `${serverUrl}/${ApiPath.Rest}/${schemaName !== 'core' ? schemaName : ''}`,
description: 'Production Development',
},
],
@@ -8,6 +8,7 @@ import {
} from '@nestjs/common';
import { Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -26,7 +27,7 @@ import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Controller('rest/sdk-client')
@Controller(`${ApiPath.Rest}/sdk-client`)
@UseGuards(WorkspaceAuthGuard)
export class SdkClientController {
constructor(
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common';
import { Request, Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
import { ServerRouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger-rest-api-exception-filter';
@@ -16,7 +17,7 @@ import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('webhooks/server')
@Controller(`${ApiPath.Webhooks}/server`)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@UseFilters(ServerRouteTriggerRestApiExceptionFilter)
export class ServerRouteTriggerController {
@@ -1,6 +1,7 @@
import { Controller, Get, Header, Req, UseGuards } from '@nestjs/common';
import { type Request } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { buildApiCatalog } from 'src/engine/core-modules/well-known/utils/build-api-catalog.util';
import { buildMcpServerCard } from 'src/engine/core-modules/well-known/utils/build-mcp-server-card.util';
@@ -13,7 +14,7 @@ import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version
const DISCOVERY_CACHE_CONTROL = 'public, max-age=3600';
const FALLBACK_SERVER_VERSION = '0.0.0';
@Controller('.well-known')
@Controller(ApiPath.WellKnown)
export class WellKnownController {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@@ -1,4 +1,5 @@
import { DOCUMENTATION_BASE_URL } from 'twenty-shared/constants';
import { ApiPath } from 'twenty-shared/types';
const API_DOCS_URL = `${DOCUMENTATION_BASE_URL}/developers/extend/api`;
const MCP_DOCS_URL = `${DOCUMENTATION_BASE_URL}/user-guide/ai/capabilities/mcp`;
@@ -8,34 +9,40 @@ const MCP_DOCS_URL = `${DOCUMENTATION_BASE_URL}/user-guide/ai/capabilities/mcp`;
export const buildApiCatalog = (baseUrl: string) => ({
linkset: [
{
anchor: `${baseUrl}/rest`,
anchor: `${baseUrl}/${ApiPath.Rest}`,
'service-desc': [
{ href: `${baseUrl}/rest/open-api/core`, type: 'application/json' },
{
href: `${baseUrl}/${ApiPath.Rest}/open-api/core`,
type: 'application/json',
},
],
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
'service-meta': [
{
href: `${baseUrl}/.well-known/oauth-protected-resource`,
href: `${baseUrl}/${ApiPath.WellKnown}/oauth-protected-resource`,
type: 'application/json',
},
],
},
{
anchor: `${baseUrl}/rest/metadata`,
anchor: `${baseUrl}/${ApiPath.Rest}/metadata`,
'service-desc': [
{ href: `${baseUrl}/rest/open-api/metadata`, type: 'application/json' },
{
href: `${baseUrl}/${ApiPath.Rest}/open-api/metadata`,
type: 'application/json',
},
],
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
},
{
anchor: `${baseUrl}/graphql`,
anchor: `${baseUrl}/${ApiPath.GraphQL}`,
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
},
{
anchor: `${baseUrl}/mcp`,
anchor: `${baseUrl}/${ApiPath.Mcp}`,
'service-desc': [
{
href: `${baseUrl}/.well-known/mcp/server-card.json`,
href: `${baseUrl}/${ApiPath.WellKnown}/mcp/server-card.json`,
type: 'application/json',
},
],
@@ -10,7 +10,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Request } from 'express';
import { FieldActorSource } from 'twenty-shared/types';
import { ApiPath, FieldActorSource } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -37,7 +37,7 @@ import {
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service';
@Controller('webhooks')
@Controller(ApiPath.Webhooks)
@UseFilters(
WorkflowTriggerRestApiExceptionFilter,
PermissionsGraphqlApiExceptionFilter,
@@ -2,6 +2,7 @@ import { Body, Controller, Post, UseFilters, UseGuards } from '@nestjs/common';
import { generateText } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApiPath } from 'twenty-shared/types';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
@@ -22,7 +23,7 @@ import { GenerateTextInput } from 'src/engine/metadata-modules/ai/ai-generate-te
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
@Controller('rest/ai')
@Controller(`${ApiPath.Rest}/ai`)
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -17,7 +17,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { ApiPath, FeatureFlagKey } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
@@ -59,7 +59,7 @@ import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-mod
import { computeUniqueFieldMetadataIdsFromFlatIndexMaps } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-field-metadata-ids-from-flat-index-maps.util';
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
@Controller('rest/metadata/fields')
@Controller(`${ApiPath.Rest}/metadata/fields`)
@UseGuards(
JwtAuthGuard,
WorkspaceAuthGuard,
@@ -11,7 +11,7 @@ import {
import { pipeline } from 'stream/promises';
import { Response } from 'express';
import { FileFolder } from 'twenty-shared/types';
import { ApiPath, FileFolder } from 'twenty-shared/types';
import {
FileStorageException,
@@ -34,7 +34,7 @@ import { FrontComponentService } from 'src/engine/metadata-modules/front-compone
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/front-components')
@Controller(`${ApiPath.Rest}/front-components`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -17,7 +17,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { ApiPath, FeatureFlagKey } from 'twenty-shared/types';
import { In, Repository } from 'typeorm';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
@@ -61,7 +61,7 @@ import {
} from 'src/engine/metadata-modules/object-metadata/utils/to-legacy-object-metadata-response.util';
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
@Controller('rest/metadata/objects')
@Controller(`${ApiPath.Rest}/metadata/objects`)
@UseGuards(
JwtAuthGuard,
WorkspaceAuthGuard,
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -34,7 +35,7 @@ import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-ta
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/pageLayoutTabs')
@Controller(`${ApiPath.Rest}/metadata/pageLayoutTabs`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -13,6 +13,7 @@ import {
import { isDefined } from 'class-validator';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApiPath } from 'twenty-shared/types';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -34,7 +35,7 @@ import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/pageLayoutWidgets')
@Controller(`${ApiPath.Rest}/metadata/pageLayoutWidgets`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -29,7 +30,7 @@ import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/servi
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/pageLayouts')
@Controller(`${ApiPath.Rest}/metadata/pageLayouts`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -12,7 +12,7 @@ import {
} from '@nestjs/common';
import { Request, Response } from 'express';
import { HTTPMethod } from 'twenty-shared/types';
import { ApiPath, HTTPMethod } from 'twenty-shared/types';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@@ -20,7 +20,7 @@ import { RouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/logi
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
@Controller('s')
@Controller(ApiPath.RouteTrigger)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@UseFilters(RouteTriggerRestApiExceptionFilter)
export class RouteTriggerController {
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -36,7 +37,7 @@ import { DeleteViewFieldPermissionGuard } from 'src/engine/metadata-modules/view
import { UpdateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-field-permission.guard';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/viewFields')
@Controller(`${ApiPath.Rest}/metadata/viewFields`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -36,7 +37,7 @@ import { DeleteViewFilterGroupPermissionGuard } from 'src/engine/metadata-module
import { UpdateViewFilterGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-filter-group-permission.guard';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/viewFilterGroups')
@Controller(`${ApiPath.Rest}/metadata/viewFilterGroups`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -36,7 +37,7 @@ import { DeleteViewFilterPermissionGuard } from 'src/engine/metadata-modules/vie
import { UpdateViewFilterPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-filter-permission.guard';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/viewFilters')
@Controller(`${ApiPath.Rest}/metadata/viewFilters`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -36,7 +37,7 @@ import { DeleteViewGroupPermissionGuard } from 'src/engine/metadata-modules/view
import { UpdateViewGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-group-permission.guard';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/viewGroups')
@Controller(`${ApiPath.Rest}/metadata/viewGroups`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiPath, ViewSortDirection } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -34,9 +35,7 @@ import {
import { ViewSortRestApiExceptionFilter } from 'src/engine/metadata-modules/view-sort/filters/view-sort-rest-api-exception.filter';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
import { ViewSortDirection } from 'twenty-shared/types';
@Controller('rest/metadata/viewSorts')
@Controller(`${ApiPath.Rest}/metadata/viewSorts`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -45,7 +46,7 @@ import { FlatEntityMapsRestApiExceptionFilter } from 'src/engine/metadata-module
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/views')
@Controller(`${ApiPath.Rest}/metadata/views`)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
PermissionsRestApiExceptionFilter,
@@ -11,6 +11,7 @@ import {
} from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApiPath } from 'twenty-shared/types';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -26,7 +27,7 @@ import { type WebhookDTO } from 'src/engine/metadata-modules/webhook/dtos/webhoo
import { WebhookService } from 'src/engine/metadata-modules/webhook/webhook.service';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller(['rest/webhooks', 'rest/metadata/webhooks'])
@Controller([`${ApiPath.Rest}/webhooks`, `${ApiPath.Rest}/metadata/webhooks`])
@UseGuards(
JwtAuthGuard,
WorkspaceAuthGuard,
@@ -1,9 +1,11 @@
import { ApiPath } from 'twenty-shared/types';
interface ApolloPlaygroundOptions {
path?: string;
path?: ApiPath;
}
export const renderApolloPlayground = ({
path = 'graphql',
path = ApiPath.GraphQL,
}: ApolloPlaygroundOptions = {}) => {
return `
<!DOCTYPE html>
+3 -2
View File
@@ -8,6 +8,7 @@ import bytes from 'bytes';
import { useContainer } from 'class-validator';
import session from 'express-session';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { ApiPath } from 'twenty-shared/types';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
@@ -84,7 +85,7 @@ const bootstrap = async () => {
// Graphql file upload
app.use(
'/graphql',
`/${ApiPath.GraphQL}`,
graphqlUploadExpress({
maxFieldSize: bytes(settings.storage.maxFileSize)!,
maxFiles: 10,
@@ -92,7 +93,7 @@ const bootstrap = async () => {
);
app.use(
'/metadata',
`/${ApiPath.Metadata}`,
graphqlUploadExpress({
maxFieldSize: bytes(settings.storage.maxFileSize)!,
maxFiles: 10,
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import { type Response } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
@@ -37,7 +38,7 @@ export class ConnectedAccountSyncWebhooksController {
private readonly microsoftCalendarNotificationHandler: MicrosoftCalendarNotificationHandler,
) {}
@Post('webhooks/google/messaging')
@Post(`${ApiPath.Webhooks}/google/messaging`)
@HttpCode(HttpStatus.OK)
async handleGoogleMessaging(
@Body() body: GooglePubSubPushMessage,
@@ -49,7 +50,7 @@ export class ConnectedAccountSyncWebhooksController {
});
}
@Post('webhooks/google/calendar')
@Post(`${ApiPath.Webhooks}/google/calendar`)
@HttpCode(HttpStatus.OK)
async handleGoogleCalendar(
@Headers('x-goog-channel-id') channelId: string | undefined,
@@ -63,7 +64,7 @@ export class ConnectedAccountSyncWebhooksController {
});
}
@Post('webhooks/microsoft/messaging')
@Post(`${ApiPath.Webhooks}/microsoft/messaging`)
@HttpCode(HttpStatus.OK)
async handleMicrosoftMessaging(
@Body() body: MicrosoftGraphNotificationPayload,
@@ -79,7 +80,7 @@ export class ConnectedAccountSyncWebhooksController {
return '';
}
@Post('webhooks/microsoft/calendar')
@Post(`${ApiPath.Webhooks}/microsoft/calendar`)
@HttpCode(HttpStatus.OK)
async handleMicrosoftCalendar(
@Body() body: MicrosoftGraphNotificationPayload,
@@ -2,10 +2,9 @@ import { Injectable } from '@nestjs/common';
import { type GraphError } from '@microsoft/microsoft-graph-client';
import { type Subscription } from '@microsoft/microsoft-graph-types';
import { ApiPath, WebhookSubscriptionChannelType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WebhookSubscriptionChannelType } from 'twenty-shared/types';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { MICROSOFT_SUBSCRIPTION_TTL_MS } from 'src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/constants/microsoft-subscription-ttl-ms.constant';
import {
@@ -35,12 +34,12 @@ const MICROSOFT_GRAPH_RESOURCE_CONFIG_BY_CHANNEL_TYPE: Record<
[WebhookSubscriptionChannelType.MESSAGING]: {
resource: '/me/messages',
changeType: 'created,updated',
notificationPath: 'webhooks/microsoft/messaging',
notificationPath: `${ApiPath.Webhooks}/microsoft/messaging`,
},
[WebhookSubscriptionChannelType.CALENDAR]: {
resource: '/me/events',
changeType: 'created,updated,deleted',
notificationPath: 'webhooks/microsoft/calendar',
notificationPath: `${ApiPath.Webhooks}/microsoft/calendar`,
},
};
@@ -1,5 +1,7 @@
import { Controller, Param, Post, UseFilters, UseGuards } from '@nestjs/common';
import { ApiPath } from 'twenty-shared/types';
import { getWorkspaceAuthContext } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -8,7 +10,7 @@ import { DuplicatedDashboardDTO } from 'src/modules/dashboard/dtos/duplicated-da
import { DashboardRestApiExceptionFilter } from 'src/modules/dashboard/filters/dashboard-rest-api-exception.filter';
import { DashboardDuplicationService } from 'src/modules/dashboard/services/dashboard-duplication.service';
@Controller('rest/dashboards')
@Controller(`${ApiPath.Rest}/dashboards`)
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UseFilters(DashboardRestApiExceptionFilter)
export class DashboardController {
@@ -11,6 +11,7 @@ import {
} from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { ApiPath } from 'twenty-shared/types';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
@@ -24,8 +25,8 @@ import { MessageSuppressionService } from 'src/modules/emailing/services/message
const UNSUBSCRIBE_TOKEN_FORMAT = /^[A-Za-z0-9_-]{1,1024}$/;
const UPDATE_PREFERENCES_PATH = '/emailing/unsubscribe/preferences';
const UNSUBSCRIBE_ALL_PATH = '/emailing/unsubscribe/all';
const UPDATE_PREFERENCES_PATH = `/${ApiPath.Emailing}/unsubscribe/preferences`;
const UNSUBSCRIBE_ALL_PATH = `/${ApiPath.Emailing}/unsubscribe/all`;
const HTML_CONTENT_TYPE = 'text/html; charset=utf-8';
@@ -39,7 +40,7 @@ type UnsubscribeFormBody = {
unsubscribeTopicId?: string | string[];
};
@Controller('emailing/unsubscribe')
@Controller(`${ApiPath.Emailing}/unsubscribe`)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
export class UnsubscribeController {
constructor(
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common';
import { type Request } from 'express';
import { ApiPath } from 'twenty-shared/types';
import { MessagingWebhookApiExceptionFilter } from 'src/modules/messaging-webhooks/filters/messaging-webhook-api-exception.filter';
import { MessagingWebhookExceptionCode } from 'src/modules/messaging-webhooks/messaging-webhook-exception-code.enum';
@@ -27,7 +28,7 @@ export class MessagingWebhooksController {
private readonly sesOutboundWebhookRouterService: SesOutboundWebhookRouterService,
) {}
@Post(['webhooks/messaging/ses/inbound'])
@Post(`${ApiPath.Webhooks}/messaging/ses/inbound`)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@HttpCode(200)
async handleSesInboundWebhook(
@@ -43,7 +44,7 @@ export class MessagingWebhooksController {
await this.sesInboundWebhookRouterService.route(request.rawBody);
}
@Post(['webhooks/messaging/ses/outbound'])
@Post(`${ApiPath.Webhooks}/messaging/ses/outbound`)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@HttpCode(200)
async handleSesOutboundWebhook(
@@ -0,0 +1,26 @@
// Adding or renaming a value here also requires updating the nginx ingress rules
// in the infra repo, which route these prefixes to the server instead of the front.
export enum ApiPath {
AdminPanel = 'admin-panel',
App = 'app',
ApplicationRegistrationClaim = 'application-registration-claim',
Apps = 'apps',
Auth = 'auth',
ClientConfig = 'client-config',
Cloudflare = 'cloudflare',
Emailing = 'emailing',
File = 'file',
FileUpload = 'file-upload',
Files = 'files',
GraphQL = 'graphql',
Health = 'healthz',
Mcp = 'mcp',
Metadata = 'metadata',
OAuth = 'oauth',
OpenApi = 'open-api',
PublicAssets = 'public-assets',
Rest = 'rest',
RouteTrigger = 's',
Webhooks = 'webhooks',
WellKnown = '.well-known',
}
@@ -1,5 +1,4 @@
export enum AppBasePath {
Auth = '/auth',
Settings = '/settings',
Root = '/',
}
@@ -0,0 +1,29 @@
import { ApiPath } from '@/types/ApiPath';
import { AppBasePath } from '@/types/AppBasePath';
import { AppPath } from '@/types/AppPath';
const apiPaths = new Set<string>(Object.values(ApiPath));
const getFirstPathSegment = (path: string) =>
path.replace(/^\//, '').split('/')[0];
const frontRoutes = [...Object.values(AppPath), ...Object.values(AppBasePath)];
const frontRoutesWithSegment = frontRoutes
.map((frontRoute) => ({
frontRoute,
firstPathSegment: getFirstPathSegment(frontRoute),
}))
.filter(
({ firstPathSegment }) =>
firstPathSegment !== '' && firstPathSegment !== '*',
);
describe('ApiPath and front route collisions', () => {
it.each(frontRoutesWithSegment)(
'should not serve the front route $frontRoute from a path the server owns',
({ firstPathSegment }) => {
expect(apiPaths.has(firstPathSegment)).toBe(false);
},
);
});
@@ -11,6 +11,7 @@ export type { AllowedAddressSubField } from './AddressFieldsType';
export { ALLOWED_ADDRESS_SUBFIELDS } from './AddressFieldsType';
export { AggregateOperations } from './AggregateOperations';
export type { AllowedFullNameSortSubField } from './AllowedFullNameSortSubField';
export { ApiPath } from './ApiPath';
export { AppBasePath } from './AppBasePath';
export { AppPath } from './AppPath';
export type { Arrayable } from './Arrayable';