diff --git a/examples/agent-frameworks/n8n/README.md b/examples/agent-frameworks/n8n/README.md new file mode 100644 index 0000000..84214cd --- /dev/null +++ b/examples/agent-frameworks/n8n/README.md @@ -0,0 +1,79 @@ +# APort n8n Policy Check Node + +Custom n8n community node for checking an agent against an APort policy inside an automation workflow. + +## Features + +- Calls the APort policy verification endpoint for every incoming item +- Supports n8n expressions for the policy ID, agent ID, and context fields +- Adds the verification result to the item JSON or returns only the APort result +- Provides credential storage for the APort API key and API base URL +- Handles invalid context JSON and failed API calls with item-level n8n errors + +## Install Locally + +```bash +cd examples/agent-frameworks/n8n +npm test +``` + +To test the node in a local n8n instance, follow the n8n community-node development workflow and link this package into your custom extensions directory. + +## Credentials + +Create an **APort API** credential in n8n: + +| Field | Description | +| --- | --- | +| API Key | APort API key used for policy verification | +| Base URL | APort API base URL. Defaults to `https://aport.io` | + +## Node Configuration + +| Field | Description | +| --- | --- | +| Policy ID | APort policy pack identifier, for example `code.repository.merge.v1` | +| Agent ID | Agent passport identifier to verify | +| Context | Optional JSON object sent as the verification context | +| Include Input Data | When enabled, keeps incoming item data and writes the result to `aport` | + +## Example Workflow Shape + +1. Add any trigger node, such as **Manual Trigger**. +2. Add **APort Policy Check**. +3. Configure the APort credential. +4. Set: + +```json +{ + "policyId": "code.repository.merge.v1", + "agentId": "agt_123", + "context": { + "repository": "aporthq/aport-integrations", + "action": "merge" + } +} +``` + +The output includes: + +```json +{ + "aport": { + "allow": true, + "reasons": [], + "policyId": "code.repository.merge.v1", + "agentId": "agt_123", + "raw": { + "allow": true, + "reasons": [] + } + } +} +``` + +## Verification + +```bash +npm test +``` diff --git a/examples/agent-frameworks/n8n/credentials/APortApi.credentials.js b/examples/agent-frameworks/n8n/credentials/APortApi.credentials.js new file mode 100644 index 0000000..334c868 --- /dev/null +++ b/examples/agent-frameworks/n8n/credentials/APortApi.credentials.js @@ -0,0 +1,31 @@ +class APortApi { + constructor() { + this.name = "aportApi"; + this.displayName = "APort API"; + this.documentationUrl = "https://aport.io/docs"; + this.properties = [ + { + displayName: "API Key", + name: "apiKey", + type: "string", + typeOptions: { + password: true, + }, + default: "", + required: true, + }, + { + displayName: "Base URL", + name: "baseUrl", + type: "string", + default: "https://aport.io", + required: true, + description: "Base URL for the APort API.", + }, + ]; + } +} + +module.exports = { + APortApi, +}; diff --git a/examples/agent-frameworks/n8n/index.js b/examples/agent-frameworks/n8n/index.js new file mode 100644 index 0000000..c308ab3 --- /dev/null +++ b/examples/agent-frameworks/n8n/index.js @@ -0,0 +1,5 @@ +module.exports = { + APortApi: require("./credentials/APortApi.credentials").APortApi, + APortPolicyCheck: + require("./nodes/APortPolicyCheck/APortPolicyCheck.node").APortPolicyCheck, +}; diff --git a/examples/agent-frameworks/n8n/nodes/APortPolicyCheck/APortPolicyCheck.node.js b/examples/agent-frameworks/n8n/nodes/APortPolicyCheck/APortPolicyCheck.node.js new file mode 100644 index 0000000..82f7150 --- /dev/null +++ b/examples/agent-frameworks/n8n/nodes/APortPolicyCheck/APortPolicyCheck.node.js @@ -0,0 +1,157 @@ +function normalizeBaseUrl(baseUrl) { + return String(baseUrl || "https://aport.io").replace(/\/+$/, ""); +} + +function parseContext(rawContext) { + const trimmed = String(rawContext || "").trim(); + + if (!trimmed) { + return {}; + } + + const parsed = JSON.parse(trimmed); + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Context must be a JSON object."); + } + + return parsed; +} + +class APortPolicyCheck { + constructor() { + this.description = { + displayName: "APort Policy Check", + name: "aportPolicyCheck", + icon: "fa:shield-alt", + group: ["transform"], + version: 1, + description: "Verify an APort agent passport against a policy pack", + defaults: { + name: "APort Policy Check", + }, + inputs: ["main"], + outputs: ["main"], + credentials: [ + { + name: "aportApi", + required: true, + }, + ], + properties: [ + { + displayName: "Policy ID", + name: "policyId", + type: "string", + default: "code.repository.merge.v1", + required: true, + description: "APort policy pack identifier to evaluate.", + }, + { + displayName: "Agent ID", + name: "agentId", + type: "string", + default: "", + required: true, + placeholder: "agt_123", + description: "Agent passport identifier to verify.", + }, + { + displayName: "Context", + name: "context", + type: "json", + default: "{}", + description: "JSON object sent as the APort verification context.", + }, + { + displayName: "Include Input Data", + name: "includeInputData", + type: "boolean", + default: true, + description: + "Whether to keep the incoming item JSON and attach the APort result under the aport key.", + }, + ], + }; + } + + async execute() { + const items = this.getInputData(); + const credentials = await this.getCredentials("aportApi"); + const baseUrl = normalizeBaseUrl(credentials.baseUrl); + const returnData = []; + + for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { + try { + const policyId = this.getNodeParameter("policyId", itemIndex); + const agentId = this.getNodeParameter("agentId", itemIndex); + const context = parseContext( + this.getNodeParameter("context", itemIndex, "{}"), + ); + const includeInputData = this.getNodeParameter( + "includeInputData", + itemIndex, + true, + ); + + const response = await this.helpers.httpRequest({ + method: "POST", + url: `${baseUrl}/api/verify/policy/${encodeURIComponent(policyId)}`, + headers: { + Accept: "application/json", + Authorization: `Bearer ${credentials.apiKey}`, + "Content-Type": "application/json", + }, + body: { + agent_id: agentId, + context, + policy_id: policyId, + }, + json: true, + }); + + const aportResult = { + allow: Boolean(response.allow ?? response.allowed), + reasons: Array.isArray(response.reasons) ? response.reasons : [], + policyId, + agentId, + raw: response, + }; + + returnData.push({ + json: includeInputData + ? { + ...items[itemIndex].json, + aport: aportResult, + } + : aportResult, + pairedItem: { + item: itemIndex, + }, + }); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { + error: error.message, + }, + pairedItem: { + item: itemIndex, + }, + }); + continue; + } + + throw error; + } + } + + return [returnData]; + } +} + +module.exports = { + APortPolicyCheck, + normalizeBaseUrl, + parseContext, +}; diff --git a/examples/agent-frameworks/n8n/package.json b/examples/agent-frameworks/n8n/package.json new file mode 100644 index 0000000..aa46ed4 --- /dev/null +++ b/examples/agent-frameworks/n8n/package.json @@ -0,0 +1,33 @@ +{ + "name": "n8n-nodes-aport-policy-check", + "version": "0.1.0", + "description": "n8n community node for APort policy verification.", + "license": "MIT", + "author": "APort community", + "keywords": [ + "n8n-community-node-package", + "n8n", + "aport", + "policy", + "verification" + ], + "main": "index.js", + "files": [ + "credentials", + "nodes", + "index.js", + "README.md" + ], + "scripts": { + "smoke": "node test/smoke.js", + "test": "npm run smoke" + }, + "n8n": { + "credentials": [ + "credentials/APortApi.credentials.js" + ], + "nodes": [ + "nodes/APortPolicyCheck/APortPolicyCheck.node.js" + ] + } +} diff --git a/examples/agent-frameworks/n8n/test/smoke.js b/examples/agent-frameworks/n8n/test/smoke.js new file mode 100644 index 0000000..eb2db8c --- /dev/null +++ b/examples/agent-frameworks/n8n/test/smoke.js @@ -0,0 +1,33 @@ +const assert = require("node:assert/strict"); + +const { APortApi } = require("../credentials/APortApi.credentials"); +const { + APortPolicyCheck, + normalizeBaseUrl, + parseContext, +} = require("../nodes/APortPolicyCheck/APortPolicyCheck.node"); + +const credentials = new APortApi(); +const node = new APortPolicyCheck(); + +assert.equal(credentials.name, "aportApi"); +assert.equal(credentials.properties.some((property) => property.name === "apiKey"), true); +assert.equal(credentials.properties.some((property) => property.name === "baseUrl"), true); + +assert.equal(node.description.name, "aportPolicyCheck"); +assert.equal(node.description.credentials[0].name, "aportApi"); +assert.deepEqual(node.description.inputs, ["main"]); +assert.deepEqual(node.description.outputs, ["main"]); +assert.equal( + node.description.properties.some((property) => property.name === "policyId"), + true, +); +assert.equal( + node.description.properties.some((property) => property.name === "agentId"), + true, +); +assert.deepEqual(parseContext('{"action":"merge"}'), { action: "merge" }); +assert.equal(normalizeBaseUrl("https://aport.io/"), "https://aport.io"); +assert.throws(() => parseContext("[]"), /JSON object/); + +console.log("APort n8n node smoke test passed");