feat(core): Support multiple base URLs in n8n Connect synthetic credentials (no-changelog) (#32495)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alexander Gekov
2026-06-18 19:04:55 +03:00
committed by GitHub
parent 2dac96d6ed
commit a70eb97822
3 changed files with 106 additions and 5 deletions
@@ -6,6 +6,10 @@ const aiGatewayProviderConfigEntryShape = {
gatewayPath: z.string(),
urlField: z.string(),
apiKeyField: z.string(),
// Maps a credential URL field to the gateway path it should be rewritten to.
// Lets a single credential fan out to multiple gateway providers. When present,
// it is authoritative over `urlField`/`gatewayPath`.
routing: z.record(z.string()).optional(),
};
export class AiGatewayProviderConfigEntry extends Z.class(aiGatewayProviderConfigEntryShape) {}
@@ -441,6 +441,80 @@ describe('AiGatewayService', () => {
});
});
it('fans a single credential out to multiple gateway URLs when routing is present', async () => {
const routingConfig = {
...MOCK_GATEWAY_CONFIG,
credentialTypes: ['browserbaseApi'],
providerConfig: {
browserbaseApi: {
gatewayPath: '/v1/gateway/browserbase',
urlField: 'baseUrl',
apiKeyField: 'browserbaseApiKey',
routing: {
baseUrl: '/v1/gateway/browserbase',
stagehandBaseUrl: '/v1/gateway/browserbaseStagehand',
},
},
},
};
fetchMock
.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue(routingConfig) })
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue({ token: 'mock-jwt-token', expiresIn: 3600 }),
});
const service = makeService();
const result = await service.getSyntheticCredential({
credentialType: 'browserbaseApi',
userId: USER_ID,
});
expect(result).toEqual({
browserbaseApiKey: 'mock-jwt-token',
baseUrl: `${BASE_URL}/v1/gateway/browserbase`,
stagehandBaseUrl: `${BASE_URL}/v1/gateway/browserbaseStagehand`,
});
});
it('embeds exec context in every routed gateway URL', async () => {
const routingConfig = {
...MOCK_GATEWAY_CONFIG,
credentialTypes: ['browserbaseApi'],
providerConfig: {
browserbaseApi: {
gatewayPath: '/v1/gateway/browserbase',
urlField: 'baseUrl',
apiKeyField: 'browserbaseApiKey',
routing: {
baseUrl: '/v1/gateway/browserbase',
stagehandBaseUrl: '/v1/gateway/browserbaseStagehand',
},
},
},
};
fetchMock
.mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue(routingConfig) })
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue({ token: 'mock-jwt-token', expiresIn: 3600 }),
});
const service = makeService();
const result = await service.getSyntheticCredential({
credentialType: 'browserbaseApi',
userId: USER_ID,
executionId: '29021',
workflowId: 'R9JFXwkUCL1jZBuw',
});
expect(result).toEqual({
browserbaseApiKey: 'mock-jwt-token',
baseUrl: `${BASE_URL}/v1/gateway/exec/29021/R9JFXwkUCL1jZBuw/browserbase`,
stagehandBaseUrl: `${BASE_URL}/v1/gateway/exec/29021/R9JFXwkUCL1jZBuw/browserbaseStagehand`,
});
});
it('uses standard gateway URL when gatewayPath does not start with the gateway prefix', async () => {
const customConfig = {
...MOCK_GATEWAY_CONFIG,
@@ -121,14 +121,37 @@ export class AiGatewayService {
throw new UserError('Failed to obtain a valid AI Gateway token.');
}
const gatewayUrl = this.buildGatewayUrl(baseUrl, providerConfig.gatewayPath, {
executionId,
workflowId,
});
const urlFields = this.buildUrlFields(baseUrl, providerConfig, { executionId, workflowId });
return {
[providerConfig.apiKeyField]: jwt,
[providerConfig.urlField]: gatewayUrl,
...urlFields,
};
}
/**
* Builds the credential URL field(s) pointing at the gateway.
*
* When `routing` is present, each `<credential field> → <gateway path>` entry is
* rewritten to its gateway URL, letting a single credential fan out to multiple
* gateway providers. Otherwise falls back to the single `urlField`/`gatewayPath`.
*/
private buildUrlFields(
baseUrl: string,
providerConfig: { gatewayPath: string; urlField: string; routing?: Record<string, string> },
context: { executionId?: string; workflowId?: string },
): Record<string, string> {
const routing = providerConfig.routing;
if (routing && Object.keys(routing).length > 0) {
return Object.fromEntries(
Object.entries(routing).map(([urlField, gatewayPath]) => [
urlField,
this.buildGatewayUrl(baseUrl, gatewayPath, context),
]),
);
}
return {
[providerConfig.urlField]: this.buildGatewayUrl(baseUrl, providerConfig.gatewayPath, context),
};
}