Add n8n connection preset and workflow step operations (#7447)

# Description of Changes

Add n8n, simple reuse of webhook call etc and custom API  etc etc


<img width="2104" height="1090" alt="image"
src="https://github.com/user-attachments/assets/0d0df39a-31b3-4e1b-ac63-847aefa06ffe"
/>

<img width="2104" height="1592" alt="image"
src="https://github.com/user-attachments/assets/4074cce2-0f4b-4300-813c-d49576b33940"
/>

<img width="1021" height="797" alt="image"
src="https://github.com/user-attachments/assets/370f54f7-3066-4454-9618-9ddd2cb91b83"
/>

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-08-14 18:07:12 +01:00
committed by GitHub
parent 2483e9f37a
commit bff1ea916d
6 changed files with 423 additions and 1 deletions
@@ -6934,6 +6934,32 @@ label = "Jira"
description = "Email a document or a notification when a policy runs."
label = "Mailgun"
[portal.connections.types.n8n]
baseUrlPlaceholder = "https://your-n8n/webhook/9f2c-..."
description = "Hand documents to an n8n workflow, and take back whatever it produces."
label = "n8n"
[portal.connections.types.n8n.fields.authType]
helperText = "Match the Authentication set on the n8n Webhook node."
label = "Webhook authentication"
[portal.connections.types.n8n.fields.authType.options.basic]
label = "Basic auth"
[portal.connections.types.n8n.fields.authType.options.header]
label = "Header auth"
[portal.connections.types.n8n.fields.authType.options.none]
label = "None"
[portal.connections.types.n8n.fields.headerName]
helperText = "The header name from the n8n credential, not its value."
label = "Header name"
placeholder = "X-N8N-Key"
[portal.connections.types.n8n.fields.token]
label = "Header value"
[portal.connections.types.nextcloud]
baseUrlPlaceholder = "https://your-server/remote.php/dav"
description = "File processed documents into Nextcloud."
@@ -8118,6 +8144,24 @@ label = "Attach to a Jira issue"
description = "Sends the processed document as an attachment."
label = "Email the document (Mailgun)"
[portal.policies.operations.n8nGate]
description = "Your workflow decides whether the run carries on."
label = "Ask an n8n workflow to approve"
note = "The workflow must reply with {\"approved\": true}. Anything else — including a timeout — stops the run and parks the document."
[portal.policies.operations.n8nNotify]
description = "Sends the run's details, not the document."
label = "Tell an n8n workflow"
[portal.policies.operations.n8nSend]
description = "Sends the document and the run's details to your workflow."
label = "Send the document to n8n"
[portal.policies.operations.n8nTransform]
description = "Your workflow processes the document and the file it returns replaces it."
label = "Process the document in n8n"
note = "Set the Webhook node to respond \"Using Respond to Webhook Node\", and return a file. On the default \"Immediately\" setting n8n replies with an acknowledgement, and that would replace the document."
[portal.policies.operations.nextcloudUpload]
description = "Writes the processed document into a folder."
label = "Upload to Nextcloud"
@@ -188,6 +188,21 @@ const BRAND: Record<string, (p: MarkProps) => ReactNode> = {
<circle cx="12" cy="12" r="2.6" fill="#FF4F00" />
</Fill>
),
n8n: (p) => (
<Fill {...p}>
<g stroke="#EA4B71" strokeWidth="1.6" fill="none" strokeLinecap="round">
<path d="M3.6 12h5.2" />
<path d="M13.2 12h1.2c1.1 0 1.5-.8 2-1.7s.9-1.7 2-1.7h1" />
<path d="M13.2 12h1.2c1.1 0 1.5.8 2 1.7s.9 1.7 2 1.7h1" />
</g>
<g fill="#EA4B71">
<circle cx="2.6" cy="12" r="1.7" />
<circle cx="11" cy="12" r="2.4" />
<circle cx="20.6" cy="8.6" r="1.7" />
<circle cx="20.6" cy="15.4" r="1.7" />
</g>
</Fill>
),
jira: (p) => (
<Fill {...p}>
<path
@@ -229,6 +229,90 @@ describe("the Cloudmersive scan gate", () => {
});
});
describe("the n8n operations", () => {
it("covers each of the engine's response behaviours exactly once", () => {
// A Webhook node can reply, so n8n reaches replace and requireTrue as well as report;
// collapsed onto notify() the extra three would silently become fire-and-forget.
const shape = (id: string) => {
const params = buildStepParameters(operationById(id)!, "1", {
message: "x",
});
return {
includeFile: params.includeFile,
responseMode: params.responseMode,
requireTrue: params.requireTrue,
};
};
expect(shape("n8nNotify")).toEqual({
includeFile: "false",
responseMode: "report",
requireTrue: "",
});
expect(shape("n8nSend")).toEqual({
includeFile: "true",
responseMode: "report",
requireTrue: "",
});
expect(shape("n8nTransform")).toEqual({
includeFile: "true",
responseMode: "replace",
requireTrue: "",
});
expect(shape("n8nGate")).toEqual({
includeFile: "true",
responseMode: "report",
requireTrue: "approved",
});
});
it("sends the facts as fields rather than one sentence to parse", () => {
const params = buildStepParameters(operationById("n8nNotify")!, "1", {
message: "done",
});
const body = JSON.parse(params.bodyTemplate) as Record<string, unknown>;
expect(body.message).toBe("done");
expect(body.document).toEqual({
filename: "{{document.filename}}",
extension: "{{document.extension}}",
contentType: "{{document.contentType}}",
sizeBytes: "{{document.sizeBytes}}",
sha256: "{{document.sha256}}",
});
expect(body.run).toMatchObject({ policy: "{{run.policyName}}" });
});
it("uses no PDF-only placeholder in a fixed body", () => {
// document.pageCount is added only for PDFs, and the backend throws on a placeholder it cannot
// resolve - baking it in would fail the step on the first non-PDF that reached it.
for (const id of ["n8nNotify", "n8nSend", "n8nTransform", "n8nGate"]) {
const params = buildStepParameters(operationById(id)!, "1", {
message: "x",
});
expect(params.bodyTemplate, id).not.toContain("pageCount");
}
});
it("gives the workflow the run's context beside the file", () => {
for (const id of ["n8nSend", "n8nTransform", "n8nGate"]) {
const params = buildStepParameters(operationById(id)!, "1", {});
expect(params.includeContext, id).toBe("true");
expect(params.bodyMode, id).toBe("multipart");
}
});
it("leaves every other vendor's context off", () => {
// includeContext used to be hardcoded false; now that it is per-operation, a vendor with a
// fixed API would reject the extra part.
const optedIn = STEP_OPERATIONS.filter(
(op) => buildStepParameters(op, "1", {}).includeContext === "true",
).map((op) => op.id);
expect(optedIn.sort()).toEqual(["n8nGate", "n8nSend", "n8nTransform"]);
});
});
describe("substituting an answer into the URL path", () => {
it("percent-encodes the answer so a slash stays a value, not a new segment", () => {
// A Jira key like "OPS/1" (or a space) must not add a path segment or reach another endpoint.
@@ -83,6 +83,9 @@ export interface OperationCall {
bodyTemplate?: string;
/** False for notify-style calls that send facts rather than the document. */
includeFile?: boolean;
/** Send the fact context beside the document so the receiver can branch on the run.
* Off by default: a vendor with a fixed API has no field for it. */
includeContext?: boolean;
}
export interface StepOperation {
@@ -153,6 +156,138 @@ function notify(
};
}
/** Facts every document has, whatever its type — `document.pageCount` is PDF-only and would
* fail the step on anything else, so it is offered in the editable message rather than baked in. */
const N8N_FACTS = {
event: "policy.document.processed",
run: {
policy: "{{run.policyName}}",
id: "{{run.runId}}",
at: "{{run.timestamp}}",
},
document: {
filename: "{{document.filename}}",
extension: "{{document.extension}}",
contentType: "{{document.contentType}}",
sizeBytes: "{{document.sizeBytes}}",
sha256: "{{document.sha256}}",
},
};
/** The four shapes an n8n workflow can take part in; see the catalogue entry for why n8n gets more
* than one. All four post to the Webhook node's URL, which is the whole endpoint. */
function n8nOperations(): StepOperation[] {
const base = {
connectionTypeId: "n8n",
integrationType: "API" as const,
category: "notify" as const,
};
const entry = (
id: string,
call: OperationCall,
extra: Partial<StepOperation> = {},
) => ({
...base,
id,
labelKey: `${PREFIX}.${id}.label`,
descriptionKey: `${PREFIX}.${id}.description`,
call,
...extra,
});
return [
// Facts only, structured rather than one text blob: a workflow branches on
// fields instead of parsing a sentence back apart.
entry(
"n8nNotify",
{
path: "",
bodyMode: "json",
includeFile: false,
bodyTemplate: JSON.stringify({ ...N8N_FACTS, message: "{{message}}" }),
},
{
searchTerms: ["n8n", "notify", "trigger", "workflow", "automation"],
fields: [
{
key: "message",
labelKey: `${PREFIX}.fields.message.label`,
control: "textarea",
required: true,
helperTextKey: `${PREFIX}.fields.message.helperText`,
defaultValue: "{{run.policyName}} processed {{document.filename}}",
},
],
},
),
// The document itself, plus the facts as a `stirlingContext` part so the workflow knows which
// policy sent it. Fire-and-forget: whatever n8n replies is recorded, the document is unchanged.
entry(
"n8nSend",
{
path: "",
bodyMode: "multipart",
fileFieldName: "file",
includeContext: true,
responseMode: "report",
},
{
searchTerms: ["n8n", "send", "file", "upload", "workflow", "binary"],
},
),
// The round trip: the returned file replaces the one in the pipeline. Needs the Webhook node
// set to "Respond to Webhook" - on "Immediately" its acknowledgement would replace the document.
entry(
"n8nTransform",
{
path: "",
bodyMode: "multipart",
fileFieldName: "file",
includeContext: true,
responseMode: "replace",
},
{
searchTerms: [
"n8n",
"transform",
"process",
"convert",
"workflow",
"replace",
],
noteKey: `${PREFIX}.n8nTransform.note`,
},
),
// The workflow decides whether the run continues. `requireTrue` is fail-closed, so an n8n
// outage or a malformed reply parks the document rather than waving it through.
entry(
"n8nGate",
{
path: "",
bodyMode: "multipart",
fileFieldName: "file",
includeContext: true,
responseMode: "report",
requireTrue: "approved",
},
{
searchTerms: [
"n8n",
"approve",
"gate",
"review",
"decision",
"workflow",
],
noteKey: `${PREFIX}.n8nGate.note`,
},
),
];
}
export const STEP_OPERATIONS: StepOperation[] = [
// ---- scan & classify: the verdict gates the run ------------------------------------------
{
@@ -501,6 +636,11 @@ export const STEP_OPERATIONS: StepOperation[] = [
"trigger",
]),
// ---- n8n: the one vendor here that can answer back ---------------------------------------------
// A Webhook node takes binary and can return what the workflow produces, so n8n reaches all three
// engine modes - report, replace and a `requireTrue` verdict - and gets an operation for each.
...n8nOperations(),
{
id: "webhookPost",
connectionTypeId: "webhook",
@@ -660,7 +800,7 @@ export function buildStepParameters(
headers: call.headers ? substituteJson(JSON.stringify(call.headers)) : "",
fields: call.fields ? substituteJson(JSON.stringify(call.fields)) : "",
bodyTemplate: call.bodyTemplate ? substituteJson(call.bodyTemplate) : "",
includeContext: "false",
includeContext: String(call.includeContext ?? false),
includeFile: String(call.includeFile ?? true),
operationId: op.id,
operationValues: JSON.stringify(values),
@@ -136,6 +136,74 @@ describe("isFieldVisible", () => {
});
});
describe("the n8n preset", () => {
const n8n = byId("n8n");
const field = (key: string) => {
const found = n8n.fields.find((f) => f.key === key);
if (!found) throw new Error(`no field ${key}`);
return found;
};
it("offers only the authentication an n8n Webhook node can be set to", () => {
// The node does None, Basic and Header. Offering bearer or the token-login dance would be
// auth the operator cannot configure on the other end.
expect(field("authType").options?.map((o) => o.value)).toEqual([
"NONE",
"HEADER",
"BASIC",
]);
});
it("shows each credential only for the auth it belongs to", () => {
expect(isFieldVisible(field("headerName"), { authType: "HEADER" })).toBe(
true,
);
expect(isFieldVisible(field("headerName"), { authType: "NONE" })).toBe(
false,
);
expect(isFieldVisible(field("username"), { authType: "BASIC" })).toBe(true);
expect(isFieldVisible(field("username"), { authType: "HEADER" })).toBe(
false,
);
});
it("lets the operator's answer override the preset's default of no auth", () => {
// buildConnectionConfig spreads presetConfig first, so an unanswered form still yields NONE
// but a chosen HEADER must win - otherwise the credential is stored and never sent.
const open = buildConnectionConfig(n8n, {
baseUrl: "https://n8n.acme.test/webhook/abc",
});
expect(open.authType).toBe("NONE");
const secured = buildConnectionConfig(n8n, {
baseUrl: "https://n8n.acme.test/webhook/abc",
authType: "HEADER",
headerName: "X-N8N-Key",
token: "s3cr3t",
});
expect(secured).toMatchObject({
authType: "HEADER",
headerName: "X-N8N-Key",
token: "s3cr3t",
});
});
it("recovers an n8n Cloud connection from its host", () => {
expect(
connectionTypeOf("API", {
baseUrl: "https://acme.app.n8n.cloud/webhook/abc",
})?.id,
).toBe("n8n");
});
it("recovers a self-hosted n8n from its marker, since the host names nothing", () => {
const config = buildConnectionConfig(n8n, {
baseUrl: "https://automation.acme.internal/webhook/abc",
});
expect(connectionTypeOf("API", config)?.id).toBe("n8n");
});
});
describe("resolving a stored connection back to its preset", () => {
it("keeps a Discord connection as Discord, not the first API preset", () => {
// Seventeen presets share integrationType "API". Matching on that alone returned whichever
@@ -496,6 +496,57 @@ const field = {
}),
};
/** The three modes an n8n Webhook node can be set to, asked for rather than baked into the preset
* because it is the operator's choice; field values apply over `presetConfig`, so the answer wins. */
const N8N_AUTH_FIELDS: ConnectionFieldDef[] = [
{
key: "authType",
labelKey: `${PREFIX}.n8n.fields.authType.label`,
control: "select",
required: true,
defaultValue: "NONE",
helperTextKey: `${PREFIX}.n8n.fields.authType.helperText`,
options: [
{
value: "NONE",
labelKey: `${PREFIX}.n8n.fields.authType.options.none.label`,
},
{
value: "HEADER",
labelKey: `${PREFIX}.n8n.fields.authType.options.header.label`,
},
{
value: "BASIC",
labelKey: `${PREFIX}.n8n.fields.authType.options.basic.label`,
},
],
},
{
key: "headerName",
labelKey: `${PREFIX}.n8n.fields.headerName.label`,
control: "text",
required: true,
placeholderKey: `${PREFIX}.n8n.fields.headerName.placeholder`,
helperTextKey: `${PREFIX}.n8n.fields.headerName.helperText`,
visibleWhen: { key: "authType", oneOf: ["HEADER"] },
},
{
key: "token",
labelKey: `${PREFIX}.n8n.fields.token.label`,
control: "password",
required: true,
visibleWhen: { key: "authType", oneOf: ["HEADER"] },
},
{
...field.username(),
visibleWhen: { key: "authType", oneOf: ["BASIC"] },
},
{
...field.password(),
visibleWhen: { key: "authType", oneOf: ["BASIC"] },
},
];
/**
* A vendor whose integration is "an HTTP API with known mechanics". The auth shape, and the base
* URL when the vendor has a single global one, are baked into `preset`; the operator supplies only
@@ -692,6 +743,26 @@ const API_PRESETS: CreatableConnectionType[] = [
searchTerms: ["make", "automation", "workflow", "trigger", "no-code"],
identifyHosts: ["zapier.com"],
}),
// Usually self-hosted, so there is no global host to bake in. The node's three auth options are
// mirrored exactly; BEARER or TOKEN_LOGIN would be auth it cannot be configured for.
apiPreset({
id: "n8n",
category: "notify",
preset: { authType: "NONE" },
fields: [field.webhookUrl("n8n"), ...N8N_AUTH_FIELDS],
searchTerms: [
"n8n",
"automation",
"workflow",
"trigger",
"self-hosted",
"no-code",
"low-code",
],
// n8n Cloud instances live at <tenant>.app.n8n.cloud; a self-hosted one is recovered from
// its presetId marker instead, since its host is whatever the operator chose.
identifyHosts: ["n8n.cloud"],
}),
apiPreset({
id: "webhook",
category: "notify",