mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(core): Add backend form-trigger submission gate for end-user credentials (no changelog) (#35539)
This commit is contained in:
+240
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
createWorkflowWithHistory,
|
||||
getPersonalProject,
|
||||
setActiveVersion,
|
||||
testDb,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User, WorkflowEntity } from '@n8n/db';
|
||||
import { ExecutionRepository, WebhookRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node';
|
||||
import type { CredentialConnectionsRequiredResponse, INode } from 'n8n-workflow';
|
||||
import { FORM_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { agent as testAgent } from 'supertest';
|
||||
|
||||
import { SYSTEM_RESOLVER_ID } from '@/modules/dynamic-credentials.ee/constants';
|
||||
import { DynamicCredentialUserEntryStorage } from '@/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage';
|
||||
import { N8nResolverSeeder } from '@/modules/dynamic-credentials.ee/services/n8n-resolver-seeder.service';
|
||||
import { OAuthClientRepository } from '@/modules/oauth-server/database/repositories/oauth-client.repository';
|
||||
import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { WebhookServer } from '@/webhooks/webhook-server';
|
||||
|
||||
import { createCredentials } from '../shared/db/credentials';
|
||||
import { createOwner } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import { initNodeTypes, setupTestServer } from '../shared/utils';
|
||||
|
||||
setupTestServer({
|
||||
endpointGroups: ['credentials'],
|
||||
enabledFeatures: ['feat:dynamicCredentials'],
|
||||
modules: ['dynamic-credentials', 'oauth-server'],
|
||||
});
|
||||
|
||||
let owner: User;
|
||||
let submitter: User;
|
||||
let agent: SuperAgentTest;
|
||||
let formEndpoint: string;
|
||||
|
||||
const resourceUrlFor = (webhookPath: string) =>
|
||||
`${Container.get(UrlService).getWebhookBaseUrl().replace(/\/$/, '')}/${formEndpoint}/${webhookPath}`;
|
||||
|
||||
const formTriggerNode = (webhookPath: string): INode => ({
|
||||
id: randomUUID(),
|
||||
name: 'On form submission',
|
||||
type: FORM_TRIGGER_NODE_TYPE,
|
||||
typeVersion: 2.6,
|
||||
position: [0, 0],
|
||||
// v2.6 drops the `path` parameter, so the webhook path travels as `$webhookId`.
|
||||
webhookId: webhookPath,
|
||||
parameters: {
|
||||
authentication: 'n8nUserAuth',
|
||||
formTitle: 'Test Form',
|
||||
formDescription: '',
|
||||
responseMode: 'onReceived',
|
||||
formFields: { values: [{ fieldLabel: 'Name', fieldType: 'text' }] },
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Published, webhook-registered form workflow whose trigger node carries an
|
||||
* end-user (resolvable) credential. Writes the rows directly — the point of the
|
||||
* test is the runtime gate, not publish-time validation.
|
||||
*/
|
||||
const setupPublishedForm = async () => {
|
||||
const webhookPath = randomUUID();
|
||||
const node = formTriggerNode(webhookPath);
|
||||
|
||||
const credential = await createCredentials(
|
||||
{ name: 'Submitter Gmail', type: 'gmailOAuth2', data: '', isResolvable: true },
|
||||
await getPersonalProject(owner),
|
||||
);
|
||||
|
||||
node.credentials = { gmailOAuth2: { id: credential.id, name: credential.name } };
|
||||
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [node] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await Container.get(WebhookRepository).insert({
|
||||
workflowId: workflow.id,
|
||||
webhookPath,
|
||||
method: 'POST',
|
||||
node: node.name,
|
||||
});
|
||||
|
||||
return { workflow, webhookPath, credential };
|
||||
};
|
||||
|
||||
/** Mints a real audience-scoped access token for the form resource. */
|
||||
const mintAccessToken = async (userId: string, resourceUrl: string) => {
|
||||
const tokenService = Container.get(OAuthTokenService);
|
||||
// A registered client is needed only to satisfy the token rows' FK.
|
||||
const clientId = `client-${randomUUID()}`;
|
||||
await Container.get(OAuthClientRepository).save({
|
||||
id: clientId,
|
||||
name: 'Form submit gate tests',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
const pair = tokenService.generateTokenPair(userId, clientId, resourceUrl, []);
|
||||
await tokenService.saveTokenPair(pair.accessToken, pair.refreshToken, clientId, userId, []);
|
||||
return pair.accessToken;
|
||||
};
|
||||
|
||||
/** What the connect flow persists once the submitter has connected the credential. */
|
||||
const connectCredential = async (credentialId: string, userId: string) => {
|
||||
const encrypted = await Container.get(Cipher).encryptV2({ accessToken: 'submitter-secret' });
|
||||
await Container.get(DynamicCredentialUserEntryStorage).setCredentialData(
|
||||
credentialId,
|
||||
userId,
|
||||
SYSTEM_RESOLVER_ID,
|
||||
encrypted,
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
const submitForm = async (webhookPath: string, token: string) =>
|
||||
await agent
|
||||
.post(`/${formEndpoint}/${webhookPath}`)
|
||||
.set('x-auth-token', token)
|
||||
.set('content-type', 'multipart/form-data')
|
||||
.field('field-0', 'John');
|
||||
|
||||
const executionCountFor = async (workflowId: string) =>
|
||||
await Container.get(ExecutionRepository).count({ where: { workflowId } });
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.N8N_ENV_FEAT_FORM_TRIGGER_OAUTH2 = 'true';
|
||||
formEndpoint = Container.get(GlobalConfig).endpoints.form;
|
||||
|
||||
// The webhook path is served by a real `WebhookServer` running the real Form
|
||||
// Trigger, so that is the only node type the (single-node) workflow needs.
|
||||
await initNodeTypes({
|
||||
[FORM_TRIGGER_NODE_TYPE]: { type: new FormTrigger(), sourcePath: '' },
|
||||
});
|
||||
|
||||
owner = await createOwner();
|
||||
submitter = await createOwner();
|
||||
|
||||
await Container.get(CacheService).init(); // WebhookService caches static webhook lookups
|
||||
|
||||
const server = new WebhookServer();
|
||||
await server.start();
|
||||
agent = testAgent(server.app) as unknown as SuperAgentTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.N8N_ENV_FEAT_FORM_TRIGGER_OAUTH2;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'ExecutionEntity',
|
||||
'AccessToken',
|
||||
'RefreshToken',
|
||||
'AuthorizationCode',
|
||||
'OAuthClient',
|
||||
'WebhookEntity',
|
||||
'SharedWorkflow',
|
||||
'WorkflowEntity',
|
||||
'WorkflowHistory',
|
||||
'DynamicCredentialUserEntry',
|
||||
'SharedCredentials',
|
||||
'CredentialsEntity',
|
||||
'DynamicCredentialResolver',
|
||||
]);
|
||||
await Container.get(CacheService).reset();
|
||||
|
||||
// Re-seed the system resolver, which backs any resolvable credential without
|
||||
// an explicit `resolverId`. Seeding (not a hand-written row) matters: the
|
||||
// resolver's config has to be encrypted for it to be readable at resolve time.
|
||||
await Container.get(N8nResolverSeeder).seed();
|
||||
});
|
||||
|
||||
describe('form trigger submit-time credential gate', () => {
|
||||
let workflow: WorkflowEntity;
|
||||
let webhookPath: string;
|
||||
let credentialId: string;
|
||||
let token: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const fixture = await setupPublishedForm();
|
||||
workflow = fixture.workflow;
|
||||
webhookPath = fixture.webhookPath;
|
||||
credentialId = fixture.credential.id;
|
||||
token = await mintAccessToken(submitter.id, resourceUrlFor(webhookPath));
|
||||
});
|
||||
|
||||
test('rejects the submission and creates no execution when the credential is not connected', async () => {
|
||||
const response = await submitForm(webhookPath, token);
|
||||
|
||||
expect(response.statusCode).toBe(428);
|
||||
expect(response.body).toEqual({
|
||||
status: 'credential_connections_required',
|
||||
readyToExecute: false,
|
||||
credentials: [
|
||||
{
|
||||
credentialId,
|
||||
credentialName: 'Submitter Gmail',
|
||||
credentialType: 'gmailOAuth2',
|
||||
credentialStatus: 'missing',
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(executionCountFor(workflow.id)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test('accepts the submission and creates one execution once the credential is connected', async () => {
|
||||
await connectCredential(credentialId, submitter.id);
|
||||
|
||||
const response = await submitForm(webhookPath, token);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
await expect(executionCountFor(workflow.id)).resolves.toBe(1);
|
||||
});
|
||||
|
||||
test('rejects a submission made after the connection is revoked', async () => {
|
||||
await connectCredential(credentialId, submitter.id);
|
||||
expect((await submitForm(webhookPath, token)).statusCode).toBe(200);
|
||||
|
||||
await Container.get(DynamicCredentialUserEntryStorage).deleteCredentialData(
|
||||
credentialId,
|
||||
submitter.id,
|
||||
SYSTEM_RESOLVER_ID,
|
||||
{},
|
||||
);
|
||||
|
||||
const response = await submitForm(webhookPath, token);
|
||||
|
||||
expect(response.statusCode).toBe(428);
|
||||
expect((response.body as CredentialConnectionsRequiredResponse).status).toBe(
|
||||
'credential_connections_required',
|
||||
);
|
||||
await expect(executionCountFor(workflow.id)).resolves.toBe(1); // only the pre-revoke run
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { mock } from 'vitest-mock-extended';
|
||||
import { DateTime } from 'luxon';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import type {
|
||||
CredentialCheckResult,
|
||||
FormFieldsParameter,
|
||||
IDataObject,
|
||||
INode,
|
||||
@@ -32,7 +33,7 @@ import type {
|
||||
MultiPartFormData,
|
||||
NodeTypeAndVersion,
|
||||
} from 'n8n-workflow';
|
||||
import { BINARY_MODE_COMBINED } from 'n8n-workflow';
|
||||
import { BINARY_MODE_COMBINED, FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
formWebhook,
|
||||
@@ -1046,10 +1047,12 @@ describe('FormTrigger, formWebhook', () => {
|
||||
query?: IDataObject;
|
||||
headers?: Record<string, string>;
|
||||
originalUrl?: string;
|
||||
nodeType?: string;
|
||||
} = { method: 'GET' },
|
||||
) => {
|
||||
const send = vi.fn();
|
||||
const status = vi.fn(() => ({ send })) as any;
|
||||
const json = vi.fn();
|
||||
const status = vi.fn(() => ({ send, json })) as any;
|
||||
const writeHead = vi.fn();
|
||||
const end = vi.fn();
|
||||
const setHeader = vi.fn();
|
||||
@@ -1065,7 +1068,10 @@ describe('FormTrigger, formWebhook', () => {
|
||||
contentType: overrides.method === 'POST' ? 'multipart/form-data' : undefined,
|
||||
};
|
||||
|
||||
ctx.getNode.mockReturnValue({ typeVersion: 2.6 } as INode);
|
||||
ctx.getNode.mockReturnValue({
|
||||
typeVersion: 2.6,
|
||||
type: overrides.nodeType ?? FORM_TRIGGER_NODE_TYPE,
|
||||
} as INode);
|
||||
ctx.getNodeParameter.calledWith('options').mockReturnValue({});
|
||||
ctx.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form');
|
||||
ctx.getNodeParameter.calledWith('formDescription').mockReturnValue('Test Description');
|
||||
@@ -1091,7 +1097,7 @@ describe('FormTrigger, formWebhook', () => {
|
||||
ctx.getChildNodes.mockReturnValue([]);
|
||||
(ctx as any).logger = { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() };
|
||||
|
||||
return { status, send, writeHead, end, setHeader, render, cookie, clearCookie };
|
||||
return { status, send, json, writeHead, end, setHeader, render, cookie, clearCookie };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1315,6 +1321,140 @@ describe('FormTrigger, formWebhook', () => {
|
||||
expect(send).toHaveBeenCalled();
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
describe('submit-time credential readiness gate', () => {
|
||||
const notReady: CredentialCheckResult = {
|
||||
readyToExecute: false,
|
||||
credentials: [
|
||||
{
|
||||
credentialId: 'cred-missing',
|
||||
credentialName: 'My Gmail',
|
||||
credentialType: 'gmailOAuth2',
|
||||
resolverId: 'resolver-1',
|
||||
status: 'missing',
|
||||
authorizationUrl: 'https://example.com/authorize',
|
||||
revokeUrl: 'https://example.com/revoke',
|
||||
},
|
||||
{
|
||||
credentialId: 'cred-connected',
|
||||
credentialName: 'My CRM',
|
||||
credentialType: 'hubspotOAuth2',
|
||||
resolverId: 'resolver-2',
|
||||
status: 'configured',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const setupAuthedPost = (
|
||||
ctx: ReturnType<typeof mock<IWebhookFunctions>>,
|
||||
nodeType?: string,
|
||||
) => {
|
||||
const res = setupContext(ctx, {
|
||||
method: 'POST',
|
||||
headers: { 'x-auth-token': 'as-token' },
|
||||
nodeType,
|
||||
});
|
||||
ctx.validateN8nOAuth2Token.mockResolvedValue({ valid: true, user: authedUser });
|
||||
return res;
|
||||
};
|
||||
|
||||
it('returns 428 with the structured body and no workflowData when not ready', async () => {
|
||||
const ctx = mock<IWebhookFunctions>();
|
||||
const { status, json } = setupAuthedPost(ctx);
|
||||
ctx.checkTriggerCredentialStatus.mockResolvedValue(notReady);
|
||||
|
||||
const result = await formWebhook(ctx);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(428);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
status: 'credential_connections_required',
|
||||
readyToExecute: false,
|
||||
credentials: [
|
||||
{
|
||||
credentialId: 'cred-missing',
|
||||
credentialName: 'My Gmail',
|
||||
credentialType: 'gmailOAuth2',
|
||||
credentialStatus: 'missing',
|
||||
},
|
||||
{
|
||||
credentialId: 'cred-connected',
|
||||
credentialName: 'My CRM',
|
||||
credentialType: 'hubspotOAuth2',
|
||||
credentialStatus: 'configured',
|
||||
},
|
||||
],
|
||||
});
|
||||
// The connect links belong to the trusted host, not the sandboxed page.
|
||||
for (const credential of json.mock.calls[0][0].credentials) {
|
||||
expect(credential).not.toHaveProperty('authorizationUrl');
|
||||
expect(credential).not.toHaveProperty('revokeUrl');
|
||||
}
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
it('enqueues the execution when the check reports ready', async () => {
|
||||
const ctx = mock<IWebhookFunctions>();
|
||||
const { status } = setupAuthedPost(ctx);
|
||||
ctx.checkTriggerCredentialStatus.mockResolvedValue({
|
||||
readyToExecute: true,
|
||||
credentials: [notReady.credentials[1]],
|
||||
});
|
||||
|
||||
const result = await formWebhook(ctx);
|
||||
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
webhookResponse: { status: 200 },
|
||||
workflowData: [[expect.anything()]],
|
||||
});
|
||||
});
|
||||
|
||||
it('enqueues the execution when no check applies', async () => {
|
||||
const ctx = mock<IWebhookFunctions>();
|
||||
const { status } = setupAuthedPost(ctx);
|
||||
ctx.checkTriggerCredentialStatus.mockResolvedValue(undefined);
|
||||
|
||||
const result = await formWebhook(ctx);
|
||||
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
webhookResponse: { status: 200 },
|
||||
workflowData: [[expect.anything()]],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed with 503 when the check throws', async () => {
|
||||
const ctx = mock<IWebhookFunctions>();
|
||||
const { status, json } = setupAuthedPost(ctx);
|
||||
const error = new Error('could not decrypt credential context');
|
||||
ctx.checkTriggerCredentialStatus.mockRejectedValue(error);
|
||||
|
||||
const result = await formWebhook(ctx);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(503);
|
||||
expect(json).toHaveBeenCalledWith({ status: 'credential_readiness_check_failed' });
|
||||
expect(ctx.logger.error).toHaveBeenCalledWith(
|
||||
'Form submit credential readiness check failed',
|
||||
{ error },
|
||||
);
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
});
|
||||
|
||||
it('does not gate a Wait node form resume', async () => {
|
||||
const ctx = mock<IWebhookFunctions>();
|
||||
const { status } = setupAuthedPost(ctx, WAIT_NODE_TYPE);
|
||||
ctx.checkTriggerCredentialStatus.mockResolvedValue(notReady);
|
||||
|
||||
const result = await formWebhook(ctx);
|
||||
|
||||
expect(ctx.checkTriggerCredentialStatus).not.toHaveBeenCalled();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
webhookResponse: { status: 200 },
|
||||
workflowData: [[expect.anything()]],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
IWebhookFunctions,
|
||||
FormFieldsParameter,
|
||||
NodeTypeAndVersion,
|
||||
CredentialCheckResult,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
FORM_NODE_TYPE,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
BINARY_MODE_COMBINED,
|
||||
tryToParseJsonToFormFields,
|
||||
UnexpectedError,
|
||||
buildCredentialConnectionsRequiredResponse,
|
||||
} from 'n8n-workflow';
|
||||
import * as a from 'node:assert';
|
||||
import sanitize from 'sanitize-html';
|
||||
@@ -1209,23 +1211,40 @@ export async function formWebhook(
|
||||
};
|
||||
}
|
||||
|
||||
// Submit-time readiness gate, and the only real enforcement: the hosting shell's
|
||||
// disabled submit button is UX, re-enablable by author script inside the form's
|
||||
// iframe. It also works off the state at render time, so a required credential can
|
||||
// be revoked — or the page simply left open — between render and submit. Re-check
|
||||
// here, after identity establishment and before the execution is enqueued, so a
|
||||
// stale page can't spawn a run that dies at credential resolution. Scoped to the
|
||||
// trigger: the Wait node shares `formWebhook`, but its form resume continues an
|
||||
// already-running execution.
|
||||
if (node.type === FORM_TRIGGER_NODE_TYPE) {
|
||||
let readiness: CredentialCheckResult | undefined;
|
||||
try {
|
||||
readiness = await context.checkTriggerCredentialStatus();
|
||||
} catch (error) {
|
||||
// Fail closed. Throwing here is swallowed by the webhook layer, which then
|
||||
// enqueues the execution anyway — exactly the doomed run we're preventing.
|
||||
context.logger.error('Form submit credential readiness check failed', { error });
|
||||
res.status(503).json({ status: 'credential_readiness_check_failed' });
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
|
||||
if (readiness && !readiness.readyToExecute) {
|
||||
// 428, matching the webhook trigger's gate (webhook-helpers.ts) so both
|
||||
// trigger paths answer an unconnected credential the same way.
|
||||
res.status(428).json(buildCredentialConnectionsRequiredResponse(readiness));
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
}
|
||||
|
||||
let { useWorkflowTimezone } = options;
|
||||
|
||||
if (useWorkflowTimezone === undefined && node.typeVersion > 2) {
|
||||
useWorkflowTimezone = true;
|
||||
}
|
||||
|
||||
// Fail-closed submit gate: the shell panel / disabled button is UX only — this
|
||||
// server-side re-check is the real guarantee (author script in the iframe can
|
||||
// re-enable the button). Identity was established during POST authentication;
|
||||
// reject if any required credential is still missing (also covers a credential
|
||||
// revoked while the form was open — TOCTOU).
|
||||
const submitGate = await context.checkTriggerCredentialStatus();
|
||||
if (submitGate && !submitGate.readyToExecute) {
|
||||
res.status(409).json({ message: 'Required credentials are not connected yet' });
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
|
||||
const userForOutput = options.includeUserInOutput === false ? undefined : authedUser;
|
||||
const returnItem = await prepareFormReturnItem(
|
||||
context,
|
||||
|
||||
@@ -38,6 +38,7 @@ export * from './node-validation';
|
||||
export * from './node-grouping-validation';
|
||||
export * from './mcp-helpers';
|
||||
export * from './tool-helpers';
|
||||
export * from './trigger-credential-gate';
|
||||
export * from './trigger-identity';
|
||||
export * from './n8n-oauth2-auth';
|
||||
export * from './node-reference-parser-utils';
|
||||
|
||||
@@ -1517,11 +1517,12 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod
|
||||
*/
|
||||
establishTriggerIdentity(token: string, resource: string): Promise<void>;
|
||||
/**
|
||||
* Checks the status of the triggering identity's resolvable (private) credentials
|
||||
* Checks the status of the triggering identity's resolvable (end-user) credentials
|
||||
* for this workflow, using the execution context established by
|
||||
* `establishTriggerIdentity`. Returns connection URLs for any missing credential, or
|
||||
* `undefined` when no check applies (dynamic-credentials disabled or no identity
|
||||
* established). Used by the MCP trigger to gate a tool call before execution.
|
||||
* established). Used by the MCP trigger to gate a tool call, and by the Form trigger
|
||||
* to gate a submission, before an execution is enqueued.
|
||||
*/
|
||||
checkTriggerCredentialStatus(): Promise<CredentialCheckResult | undefined>;
|
||||
getInputConnectionData(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CredentialCheckResult, CredentialCheckStatus } from './interfaces';
|
||||
|
||||
/** Discriminator so a client can tell a readiness rejection from any other 4xx. */
|
||||
export const CREDENTIAL_CONNECTIONS_REQUIRED = 'credential_connections_required';
|
||||
|
||||
/**
|
||||
* One required credential in a readiness rejection. Field names mirror the
|
||||
* `GET /workflows/:id/execution-status` response, so a client that can render
|
||||
* readiness there needs no new code here. Deliberately omits `authorizationUrl`
|
||||
* and `revokeUrl`: this body is read inside the sandboxed, author-scriptable form
|
||||
* page, and the connect links belong to the trusted host that owns the session.
|
||||
*/
|
||||
export interface RequiredCredentialConnection {
|
||||
credentialId: string;
|
||||
credentialName: string;
|
||||
credentialType: string;
|
||||
credentialStatus: CredentialCheckStatus['status'];
|
||||
}
|
||||
|
||||
export interface CredentialConnectionsRequiredResponse {
|
||||
status: typeof CREDENTIAL_CONNECTIONS_REQUIRED;
|
||||
readyToExecute: false;
|
||||
credentials: RequiredCredentialConnection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a readiness check into the body a trigger returns when it refuses to
|
||||
* start an execution. Keeps every required credential, not just the missing ones,
|
||||
* so a client can render "{n} of {m} connected" without a second round-trip — the
|
||||
* missing ones are identifiable by `credentialStatus`.
|
||||
*/
|
||||
export function buildCredentialConnectionsRequiredResponse(
|
||||
result: CredentialCheckResult,
|
||||
): CredentialConnectionsRequiredResponse {
|
||||
return {
|
||||
status: CREDENTIAL_CONNECTIONS_REQUIRED,
|
||||
readyToExecute: false,
|
||||
credentials: result.credentials.map(
|
||||
({ credentialId, credentialName, credentialType, status }) => ({
|
||||
credentialId,
|
||||
credentialName,
|
||||
credentialType,
|
||||
credentialStatus: status,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CredentialCheckResult } from '../src/interfaces';
|
||||
import {
|
||||
CREDENTIAL_CONNECTIONS_REQUIRED,
|
||||
buildCredentialConnectionsRequiredResponse,
|
||||
} from '../src/trigger-credential-gate';
|
||||
|
||||
describe('buildCredentialConnectionsRequiredResponse', () => {
|
||||
const missing = {
|
||||
credentialId: 'cred-missing',
|
||||
credentialName: 'My Gmail',
|
||||
credentialType: 'gmailOAuth2',
|
||||
resolverId: 'resolver-1',
|
||||
status: 'missing' as const,
|
||||
authorizationUrl: 'https://example.com/authorize',
|
||||
revokeUrl: 'https://example.com/revoke',
|
||||
};
|
||||
const configured = {
|
||||
credentialId: 'cred-configured',
|
||||
credentialName: 'My CRM',
|
||||
credentialType: 'hubspotOAuth2',
|
||||
resolverId: 'resolver-2',
|
||||
status: 'configured' as const,
|
||||
};
|
||||
|
||||
it('renames status to credentialStatus and drops resolver/URL fields', () => {
|
||||
const result: CredentialCheckResult = { readyToExecute: false, credentials: [missing] };
|
||||
|
||||
expect(buildCredentialConnectionsRequiredResponse(result)).toEqual({
|
||||
// Asserted as a literal so a rename can't silently break clients reading the wire.
|
||||
status: 'credential_connections_required',
|
||||
readyToExecute: false,
|
||||
credentials: [
|
||||
{
|
||||
credentialId: 'cred-missing',
|
||||
credentialName: 'My Gmail',
|
||||
credentialType: 'gmailOAuth2',
|
||||
credentialStatus: 'missing',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps connected credentials and preserves input order', () => {
|
||||
const result: CredentialCheckResult = {
|
||||
readyToExecute: false,
|
||||
credentials: [configured, missing],
|
||||
};
|
||||
|
||||
const response = buildCredentialConnectionsRequiredResponse(result);
|
||||
|
||||
expect(response.credentials.map((c) => c.credentialId)).toEqual([
|
||||
'cred-configured',
|
||||
'cred-missing',
|
||||
]);
|
||||
expect(response.credentials.map((c) => c.credentialStatus)).toEqual(['configured', 'missing']);
|
||||
});
|
||||
|
||||
it('always reports readyToExecute false, even for a ready result', () => {
|
||||
const result: CredentialCheckResult = { readyToExecute: true, credentials: [configured] };
|
||||
|
||||
expect(buildCredentialConnectionsRequiredResponse(result).readyToExecute).toBe(false);
|
||||
});
|
||||
|
||||
it('handles an empty credential list', () => {
|
||||
const result: CredentialCheckResult = { readyToExecute: false, credentials: [] };
|
||||
|
||||
expect(buildCredentialConnectionsRequiredResponse(result)).toEqual({
|
||||
status: CREDENTIAL_CONNECTIONS_REQUIRED,
|
||||
readyToExecute: false,
|
||||
credentials: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a resolver_missing status through unchanged', () => {
|
||||
const result: CredentialCheckResult = {
|
||||
readyToExecute: false,
|
||||
credentials: [{ ...missing, status: 'resolver_missing' }],
|
||||
};
|
||||
|
||||
expect(buildCredentialConnectionsRequiredResponse(result).credentials[0]).toEqual({
|
||||
credentialId: 'cred-missing',
|
||||
credentialName: 'My Gmail',
|
||||
credentialType: 'gmailOAuth2',
|
||||
credentialStatus: 'resolver_missing',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user