Resolve route-trigger workspace from bearer token on bare hosts (#23490)

When a request reaches the `/s` route on a host that names no workspace
(bare `SERVER_URL` on a multiworkspace instance), resolve the workspace
from the bearer token — the same source `/graphql` uses — instead of
failing with `WORKSPACE_NOT_FOUND`. Hosts that do name a workspace keep
host resolution unchanged, and requests without a token are unaffected.

This makes the client SDK's same-site `${apiBase}/s` fallback work on
multiworkspace instances without a configured public domain: app logic
functions calling their own HTTP routes (e.g. call-recorder artifact
import) currently 404 there, because `TWENTY_FUNCTIONS_URL` is injected
empty and the bare server host carries no workspace identity. Cloud
(workspace public origin injected) and single-workspace self-host (host
resolves the default workspace) never hit this path.

Note: this also allows public routes to be reached through a bare host
when a valid token identifies the workspace. It does not change route
authorization; the token is used only for workspace resolution.
This commit is contained in:
nitin
2026-07-29 20:26:28 +05:30
committed by GitHub
parent cedb3768ec
commit e5c6cbcf80
3 changed files with 213 additions and 70 deletions
@@ -10,9 +10,11 @@ import { IsNull, Not, Repository } from 'typeorm';
import { HTTPMethod } from 'twenty-shared/types';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
RouteTriggerException,
@@ -20,6 +22,7 @@ import {
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception';
import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
import { type RouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
import { sanitizeRouteTriggerPath } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/sanitize-route-trigger-path.util';
import {
LogicFunctionException,
LogicFunctionExceptionCode,
@@ -31,6 +34,18 @@ import {
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { CustomException } from 'src/utils/custom-exception';
type RouteTriggerWorkspace = Pick<
WorkspaceEntity,
'activationStatus' | 'id' | 'subdomain'
>;
type RouteTriggerRequestContext = {
workspace: RouteTriggerWorkspace;
applicationId: string | null;
isIsolatedOrigin: boolean;
authenticationContext: AuthContext | undefined;
};
@Injectable()
export class RouteTriggerService {
private readonly logger = new Logger(RouteTriggerService.name);
@@ -44,21 +59,51 @@ export class RouteTriggerService {
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
) {}
private async getLogicFunctionWithPathParamsOrFail({
private async resolveAuthenticationContextForWorkspaceFallback({
request,
httpMethod,
workspaceFromHost,
}: {
request: Request;
httpMethod: HTTPMethod;
}): Promise<{
logicFunction: LogicFunctionEntity;
pathParams: Partial<Record<string, string | string[]>>;
isIsolatedOrigin: boolean;
}> {
workspaceFromHost: RouteTriggerWorkspace | undefined;
}): Promise<AuthContext | undefined> {
if (
isDefined(workspaceFromHost) ||
!isNonEmptyString(request.headers.authorization)
) {
return undefined;
}
try {
return await this.accessTokenService.validateTokenByRequest(request);
} catch (error) {
if (error instanceof AuthException) {
return undefined;
}
throw error;
}
}
private async resolveRouteTriggerRequestContextOrFail(
request: Request,
): Promise<RouteTriggerRequestContext> {
const host = `${request.protocol}://${request.get('host')}`;
const { workspace, publicDomain, isIsolatedOrigin } =
await this.workspaceDomainsService.resolveWorkspaceAndPublicDomain(host);
const {
workspace: workspaceFromHost,
publicDomain,
isIsolatedOrigin,
} = await this.workspaceDomainsService.resolveWorkspaceAndPublicDomain(
host,
);
const authenticationContext =
await this.resolveAuthenticationContextForWorkspaceFallback({
request,
workspaceFromHost,
});
const workspace = workspaceFromHost ?? authenticationContext?.workspace;
assertIsDefinedOrThrow(
workspace,
@@ -75,20 +120,26 @@ export class RouteTriggerService {
);
}
// App-scoped public domain → restrict matches to that app's logic functions.
const applicationId = publicDomain?.applicationId ?? null;
const logicFunctionsWithHttpRouteTrigger =
await this.logicFunctionRepository.find({
where: {
workspaceId: workspace.id,
httpRouteTriggerSettings: Not(IsNull()),
...(isDefined(applicationId) ? { applicationId } : {}),
},
});
const requestPath = request.path.replace(/^\/s\//, '/');
return {
workspace,
applicationId: publicDomain?.applicationId ?? null,
isIsolatedOrigin,
authenticationContext,
};
}
private findLogicFunctionWithPathParamsOrFail({
httpMethod,
logicFunctionsWithHttpRouteTrigger,
requestPath,
}: {
httpMethod: HTTPMethod;
logicFunctionsWithHttpRouteTrigger: LogicFunctionEntity[];
requestPath: string;
}): {
logicFunction: LogicFunctionEntity;
pathParams: Partial<Record<string, string | string[]>>;
} {
for (const logicFunction of logicFunctionsWithHttpRouteTrigger) {
const httpRouteSettings = logicFunction.httpRouteTriggerSettings;
@@ -105,16 +156,9 @@ export class RouteTriggerService {
const routeMatched = routeMatcher(requestPath);
if (routeMatched) {
this.assertLegacyRouteIsServableOrThrow({
logicFunction,
workspace,
isIsolatedOrigin,
});
return {
logicFunction,
pathParams: routeMatched.params,
isIsolatedOrigin,
};
}
}
@@ -131,7 +175,7 @@ export class RouteTriggerService {
isIsolatedOrigin,
}: {
logicFunction: LogicFunctionEntity;
workspace: WorkspaceEntity;
workspace: RouteTriggerWorkspace;
isIsolatedOrigin: boolean;
}) {
if (isIsolatedOrigin) {
@@ -177,33 +221,6 @@ export class RouteTriggerService {
}
}
private async validateWorkspaceFromRequest({
request,
workspaceId,
}: {
request: Request;
workspaceId: string;
}) {
const authContext =
await this.accessTokenService.validateTokenByRequest(request);
if (!isDefined(authContext.workspace)) {
throw new RouteTriggerException(
'Workspace not found',
RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (authContext.workspace.id !== workspaceId) {
throw new RouteTriggerException(
'You are not authorized',
RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return authContext;
}
private mapErrorToRouteTriggerCode(
error: unknown,
): RouteTriggerExceptionCode {
@@ -235,25 +252,61 @@ export class RouteTriggerService {
request: Request;
httpMethod: HTTPMethod;
}): Promise<{ response: RouteTriggerResponse; isIsolatedOrigin: boolean }> {
const { logicFunction, pathParams, isIsolatedOrigin } =
await this.getLogicFunctionWithPathParamsOrFail({
request,
httpMethod,
const {
workspace,
applicationId,
isIsolatedOrigin,
authenticationContext,
} = await this.resolveRouteTriggerRequestContextOrFail(request);
const logicFunctionsWithHttpRouteTrigger =
await this.logicFunctionRepository.find({
where: {
workspaceId: workspace.id,
httpRouteTriggerSettings: Not(IsNull()),
...(isDefined(applicationId) ? { applicationId } : {}),
},
});
const { logicFunction, pathParams } =
this.findLogicFunctionWithPathParamsOrFail({
httpMethod,
logicFunctionsWithHttpRouteTrigger,
requestPath: sanitizeRouteTriggerPath(request.path),
});
this.assertLegacyRouteIsServableOrThrow({
logicFunction,
workspace,
isIsolatedOrigin,
});
const httpRouteSettings = logicFunction.httpRouteTriggerSettings;
let userWorkspaceId: string | null = null;
let userId: string | null = null;
if (httpRouteSettings?.isAuthRequired) {
const authContext = await this.validateWorkspaceFromRequest({
request,
workspaceId: logicFunction.workspaceId,
});
const routeAuthenticationContext =
authenticationContext ??
(await this.accessTokenService.validateTokenByRequest(request));
userWorkspaceId = authContext.userWorkspaceId ?? null;
userId = authContext.user?.id ?? null;
if (!isDefined(routeAuthenticationContext.workspace)) {
throw new RouteTriggerException(
'Workspace not found',
RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (routeAuthenticationContext.workspace.id !== workspace.id) {
throw new RouteTriggerException(
'You are not authorized',
RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION,
);
}
userWorkspaceId = routeAuthenticationContext.userWorkspaceId ?? null;
userId = routeAuthenticationContext.user?.id ?? null;
}
let outcome;
@@ -0,0 +1,2 @@
export const sanitizeRouteTriggerPath = (requestPath: string): string =>
requestPath.replace(/^\/s\//, '/');
@@ -1,6 +1,8 @@
import request from 'supertest';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
@@ -16,6 +18,8 @@ const APP_UNIVERSAL_IDENTIFIER = '6a6f983f-5c1a-4c60-a3c8-7d0e2a4a66a6';
const ROLE_UNIVERSAL_IDENTIFIER = '7b7f983f-5c1a-4c60-a3c8-7d0e2a4a77b7';
const ROUTE_FUNCTION_UNIVERSAL_IDENTIFIER =
'8c8f983f-5c1a-4c60-a3c8-7d0e2a4a88c8';
const AUTHENTICATED_ROUTE_FUNCTION_UNIVERSAL_IDENTIFIER =
'9d9f983f-5c1a-4c60-a3c8-7d0e2a4a99d9';
const ROUTE_FUNCTION_RESPONSE = { greeting: 'hello from route function' };
@@ -58,6 +62,20 @@ const routeFunctionManifest: LogicFunctionManifest = {
},
};
const authenticatedRouteFunctionManifest: LogicFunctionManifest = {
universalIdentifier: AUTHENTICATED_ROUTE_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'authenticated-workspace-route',
handlerName: 'main',
sourceHandlerPath: 'src/authenticated-workspace-route.ts',
builtHandlerPath: 'dist/authenticated-workspace-route.mjs',
builtHandlerChecksum: 'checksum-authenticated-workspace-route',
httpRouteTriggerSettings: {
path: '/authenticated-workspace-route',
httpMethod: 'GET',
isAuthRequired: true,
},
};
const uploadBuiltHandlerFile = async ({
builtHandlerPath,
builtHandlerCode,
@@ -82,7 +100,9 @@ const uploadBuiltHandlerFile = async ({
describe('RouteTrigger suspended workspace (integration)', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
const bareHost = `localhost:${APP_PORT}`;
const workspaceHost = `apple.localhost:${APP_PORT}`;
let applicationAccessToken: string;
beforeAll(async () => {
await setupApplicationForSync({
@@ -97,17 +117,44 @@ describe('RouteTrigger suspended workspace (integration)', () => {
builtHandlerCode: ROUTE_BUILT_HANDLER_CODE,
});
await uploadBuiltHandlerFile({
builtHandlerPath: 'dist/authenticated-workspace-route.mjs',
builtHandlerCode: ROUTE_BUILT_HANDLER_CODE,
});
await syncApplication({
manifest: buildBaseManifest({
appId: APP_UNIVERSAL_IDENTIFIER,
roleId: ROLE_UNIVERSAL_IDENTIFIER,
overrides: {
logicFunctions: [routeFunctionManifest],
logicFunctions: [
routeFunctionManifest,
authenticatedRouteFunctionManifest,
],
},
}),
expectToFail: false,
});
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const routeTriggerApplication = applicationsData.findManyApplications.find(
(application) =>
application.universalIdentifier === APP_UNIVERSAL_IDENTIFIER,
);
expect(routeTriggerApplication).toBeDefined();
const { data: applicationTokenData } = await generateApplicationToken({
applicationId: routeTriggerApplication!.id,
expectToFail: false,
});
applicationAccessToken =
applicationTokenData.generateApplicationToken.applicationAccessToken
.token;
jest.useRealTimers();
}, 60000);
@@ -125,7 +172,48 @@ describe('RouteTrigger suspended workspace (integration)', () => {
});
}, 60000);
describe('GET /s/authenticated-workspace-route', () => {
it('should serve an authenticated route when the bearer token resolves the workspace', async () => {
const response = await request(baseUrl)
.get('/s/authenticated-workspace-route')
.set('Host', bareHost)
.set('Authorization', `Bearer ${applicationAccessToken}`);
expect(response.status).toBe(200);
expect(response.body).toEqual(ROUTE_FUNCTION_RESPONSE);
}, 60000);
});
describe('GET /s/suspended-workspace-route', () => {
it('should return WORKSPACE_NOT_FOUND when neither host nor token identifies a workspace', async () => {
const response = await request(baseUrl)
.get('/s/suspended-workspace-route')
.set('Host', bareHost);
expect(response.status).toBe(404);
expect(response.body.code).toBe('WORKSPACE_NOT_FOUND');
}, 60000);
it('should return WORKSPACE_NOT_FOUND when the bearer token is invalid on an unresolved host', async () => {
const response = await request(baseUrl)
.get('/s/suspended-workspace-route')
.set('Host', bareHost)
.set('Authorization', 'Bearer invalid-token');
expect(response.status).toBe(404);
expect(response.body.code).toBe('WORKSPACE_NOT_FOUND');
}, 60000);
it('should resolve the workspace from the bearer token when the host names none', async () => {
const response = await request(baseUrl)
.get('/s/suspended-workspace-route')
.set('Host', bareHost)
.set('Authorization', `Bearer ${applicationAccessToken}`);
expect(response.status).toBe(200);
expect(response.body).toEqual(ROUTE_FUNCTION_RESPONSE);
}, 60000);
it('serves the route trigger while the workspace is active', async () => {
const response = await request(baseUrl)
.get('/s/suspended-workspace-route')