diff --git a/examples/agent-frameworks/n8n/README.md b/examples/agent-frameworks/n8n/README.md new file mode 100644 index 0000000..fbafc2e --- /dev/null +++ b/examples/agent-frameworks/n8n/README.md @@ -0,0 +1,85 @@ +# n8n APort Verification Node + +This community node lets n8n workflows verify an APort passport against a policy pack and route each item into `Approved`, `Denied`, or `Error` outputs. + +## Features + +- Verifies a passport ID against any APort policy pack. +- Supports context from the incoming item JSON, a JSON object, or key/value fields. +- Routes workflow items by verification decision instead of requiring a separate IF node. +- Uses n8n credentials for the APort API key and base URL. +- Includes a sample workflow and unit tests for routing behavior. + +## Installation + +After the package is published, install it from an n8n instance that allows community nodes: + +```bash +npm install n8n-nodes-aport +``` + +For local development from this repository: + +```bash +cd examples/agent-frameworks/n8n +npm test +``` + +The package has no runtime dependencies; the tests use Node.js' built-in test runner. Link or copy the package into your n8n custom nodes directory according to your n8n deployment model. + +To inspect the publishable package contents locally: + +```bash +npm pack --dry-run +``` + +## Credentials + +Create an **APort API** credential in n8n: + +- `API Key`: optional bearer token for authenticated APort endpoints. +- `Base URL`: defaults to `https://aport.io`. + +The verification node can still be configured without an API key when the target APort endpoint supports public verification. + +## Node Inputs + +| Field | Description | +| --- | --- | +| `Passport ID` | Agent passport identifier. The default expression reads `agent_id`, `passport_id`, or `passportId` from incoming JSON. | +| `Policy Pack` | APort policy identifier, for example `finance.payment.refund.v1`. | +| `Context Source` | Choose incoming item JSON, a JSON object, or key/value fields. | +| `Include Input Data` | Keeps the original item JSON in the routed output. | + +## Outputs + +1. `Approved`: APort returned an allow/approved/verified decision. +2. `Denied`: APort responded successfully but did not approve the action. +3. `Error`: Missing configuration, invalid context JSON, or request errors. + +Each output item includes: + +```json +{ + "aport": { + "approved": true, + "passportId": "agt_inst_demo_123", + "policyPack": "finance.payment.refund.v1", + "response": { + "verified": true + } + } +} +``` + +## Example Workflow + +Import `workflows/aport-verify-routing.json` into n8n. The workflow creates a sample refund context, verifies it with the APort node, then exposes the three routing branches for downstream actions such as approvals, Slack alerts, or manual review. + +## Verification + +```bash +npm test +``` + +The tests cover context construction, decision parsing, approved routing, denied routing, and error routing. 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..0888a47 --- /dev/null +++ b/examples/agent-frameworks/n8n/credentials/APortApi.credentials.js @@ -0,0 +1,48 @@ +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: false, + description: + "Bearer token for APort API calls. Some verification endpoints may be usable without a key.", + }, + { + displayName: "Base URL", + name: "baseUrl", + type: "string", + default: "https://aport.io", + required: true, + description: "APort API base URL.", + }, + ]; + this.authenticate = { + type: "generic", + properties: { + headers: { + Authorization: "=Bearer {{$credentials.apiKey}}", + }, + }, + }; + this.test = { + request: { + baseURL: "={{$credentials.baseUrl}}", + url: "/api/policies", + method: "GET", + }, + }; + } +} + +module.exports = { + APortApi, +}; diff --git a/examples/agent-frameworks/n8n/nodes/APortVerify/APortVerify.node.js b/examples/agent-frameworks/n8n/nodes/APortVerify/APortVerify.node.js new file mode 100644 index 0000000..e420d5c --- /dev/null +++ b/examples/agent-frameworks/n8n/nodes/APortVerify/APortVerify.node.js @@ -0,0 +1,253 @@ +const { + buildContext, + formatError, + isApproved, + normalizeBaseUrl, +} = require("./utils"); + +class APortVerify { + constructor() { + this.description = { + displayName: "APort Verify", + name: "aportVerify", + group: ["transform"], + version: 1, + description: + "Verify an APort passport against a policy and route the workflow by decision.", + defaults: { + name: "APort Verify", + }, + inputs: ["main"], + outputs: ["main", "main", "main"], + outputNames: ["Approved", "Denied", "Error"], + credentials: [ + { + name: "aportApi", + required: false, + }, + ], + properties: [ + { + displayName: "Passport ID", + name: "passportId", + type: "string", + default: "={{$json.agent_id || $json.passport_id || $json.passportId || ''}}", + required: true, + description: "Agent passport identifier to verify.", + }, + { + displayName: "Policy Pack", + name: "policyPack", + type: "string", + default: "code.repository.merge.v1", + required: true, + description: "APort policy pack identifier.", + }, + { + displayName: "Context Source", + name: "contextMode", + type: "options", + default: "input", + options: [ + { + name: "Input JSON", + value: "input", + description: "Send the incoming item JSON as APort context.", + }, + { + name: "JSON Object", + value: "json", + description: "Send a JSON object configured on this node.", + }, + { + name: "Key/Value Fields", + value: "fields", + description: "Build context from manually configured fields.", + }, + ], + }, + { + displayName: "Context JSON", + name: "contextJson", + type: "json", + default: "{}", + displayOptions: { + show: { + contextMode: ["json"], + }, + }, + description: "JSON object sent as the APort verification context.", + }, + { + displayName: "Context Fields", + name: "contextFields", + type: "fixedCollection", + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + contextMode: ["fields"], + }, + }, + options: [ + { + name: "field", + displayName: "Field", + values: [ + { + displayName: "Key", + name: "key", + type: "string", + default: "", + }, + { + displayName: "Value", + name: "value", + type: "string", + default: "", + description: + "String values are passed as-is. JSON-looking values are parsed.", + }, + ], + }, + ], + }, + { + displayName: "Include Input Data", + name: "includeInputData", + type: "boolean", + default: true, + description: + "Whether to include the incoming item JSON in the routed output item.", + }, + { + displayName: "Base URL Override", + name: "baseUrlOverride", + type: "string", + default: "", + placeholder: "https://aport.io", + description: + "Optional base URL used when no APort credential base URL is configured.", + }, + ], + }; + } + + async execute() { + const items = this.getInputData(); + const approvedItems = []; + const deniedItems = []; + const errorItems = []; + + let credentials = {}; + try { + credentials = await this.getCredentials("aportApi"); + } catch (_error) { + credentials = {}; + } + + for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { + const inputJson = items[itemIndex].json || {}; + const includeInputData = this.getNodeParameter( + "includeInputData", + itemIndex, + true + ); + + try { + const passportId = this.getNodeParameter("passportId", itemIndex, ""); + const policyPack = this.getNodeParameter("policyPack", itemIndex, ""); + const contextMode = this.getNodeParameter("contextMode", itemIndex, "input"); + const contextJson = this.getNodeParameter("contextJson", itemIndex, "{}"); + const contextFields = this.getNodeParameter("contextFields", itemIndex, {}); + const baseUrlOverride = this.getNodeParameter( + "baseUrlOverride", + itemIndex, + "" + ); + + if (!passportId) { + throw new Error("Passport ID is required."); + } + + if (!policyPack) { + throw new Error("Policy Pack is required."); + } + + const context = buildContext({ + mode: contextMode, + inputJson, + contextJson, + fields: contextFields, + }); + const baseUrl = normalizeBaseUrl(credentials.baseUrl || baseUrlOverride); + const headers = { + "Content-Type": "application/json", + }; + + if (credentials.apiKey) { + headers.Authorization = `Bearer ${credentials.apiKey}`; + } + + const response = await this.helpers.httpRequest.call(this, { + method: "POST", + url: `${baseUrl}/api/verify/policy/${encodeURIComponent(policyPack)}`, + headers, + body: { + context: { + agent_id: passportId, + passport_id: passportId, + policy_id: policyPack, + context, + }, + }, + json: true, + timeout: 10000, + }); + + const approved = isApproved(response); + const outputItem = { + json: { + ...(includeInputData ? inputJson : {}), + aport: { + approved, + passportId, + policyPack, + response, + }, + }, + pairedItem: { + item: itemIndex, + }, + }; + + if (approved) { + approvedItems.push(outputItem); + } else { + deniedItems.push(outputItem); + } + } catch (error) { + errorItems.push({ + json: { + ...(includeInputData ? inputJson : {}), + aport: { + approved: false, + error: formatError(error), + }, + }, + pairedItem: { + item: itemIndex, + }, + }); + } + } + + return [approvedItems, deniedItems, errorItems]; + } +} + +module.exports = { + APortVerify, +}; diff --git a/examples/agent-frameworks/n8n/nodes/APortVerify/utils.js b/examples/agent-frameworks/n8n/nodes/APortVerify/utils.js new file mode 100644 index 0000000..496333f --- /dev/null +++ b/examples/agent-frameworks/n8n/nodes/APortVerify/utils.js @@ -0,0 +1,105 @@ +function normalizeBaseUrl(baseUrl) { + return (baseUrl || "https://aport.io").replace(/\/+$/, ""); +} + +function parseContextValue(value) { + if (typeof value !== "string") { + return value; + } + + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + + if (/^(\{|\[|"|-?\d|true$|false$|null$)/.test(trimmed)) { + try { + return JSON.parse(trimmed); + } catch (_error) { + return value; + } + } + + return value; +} + +function buildContext({ mode, inputJson = {}, contextJson = "{}", fields = {} }) { + if (mode === "input") { + return { ...inputJson }; + } + + if (mode === "fields") { + const entries = fields.field || []; + return entries.reduce((context, field) => { + if (field.key) { + context[field.key] = parseContextValue(field.value); + } + return context; + }, {}); + } + + if (contextJson && typeof contextJson === "object") { + if (Array.isArray(contextJson)) { + throw new Error("Context JSON must be an object."); + } + return contextJson; + } + + if (!contextJson || !contextJson.trim()) { + return {}; + } + + const parsed = JSON.parse(contextJson); + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error("Context JSON must be an object."); + } + + return parsed; +} + +function getDecisionValue(response = {}) { + return ( + response.decision || + response.verdict || + response.status || + response.result || + response.outcome || + "" + ); +} + +function isApproved(response = {}) { + if ( + response.approved === true || + response.allowed === true || + response.allow === true || + response.verified === true + ) { + return true; + } + + const decision = String(getDecisionValue(response)).toLowerCase(); + return ["allow", "allowed", "approve", "approved", "pass", "passed"].includes( + decision + ); +} + +function formatError(error) { + if (error && error.response && error.response.data) { + return ( + error.response.data.message || + error.response.data.error || + JSON.stringify(error.response.data) + ); + } + + return error && error.message ? error.message : String(error); +} + +module.exports = { + buildContext, + formatError, + isApproved, + normalizeBaseUrl, + parseContextValue, +}; diff --git a/examples/agent-frameworks/n8n/package.json b/examples/agent-frameworks/n8n/package.json new file mode 100644 index 0000000..06dc048 --- /dev/null +++ b/examples/agent-frameworks/n8n/package.json @@ -0,0 +1,37 @@ +{ + "name": "n8n-nodes-aport", + "version": "0.1.0", + "description": "n8n community node for routing workflows with APort passport and policy verification.", + "license": "MIT", + "author": "APort Community", + "main": "nodes/APortVerify/APortVerify.node.js", + "scripts": { + "test": "node --test tests/*.test.js" + }, + "files": [ + "credentials", + "nodes", + "workflows", + "README.md" + ], + "keywords": [ + "n8n-community-node-package", + "n8n", + "aport", + "agent-verification", + "passport", + "policy-checks" + ], + "n8n": { + "n8nNodesApiVersion": 1, + "credentials": [ + "credentials/APortApi.credentials.js" + ], + "nodes": [ + "nodes/APortVerify/APortVerify.node.js" + ] + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/agent-frameworks/n8n/tests/APortVerify.node.test.js b/examples/agent-frameworks/n8n/tests/APortVerify.node.test.js new file mode 100644 index 0000000..be1c0b2 --- /dev/null +++ b/examples/agent-frameworks/n8n/tests/APortVerify.node.test.js @@ -0,0 +1,132 @@ +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); +const { APortVerify } = require("../nodes/APortVerify/APortVerify.node"); + +function createExecutionContext({ parameters = {}, response, input = {} }) { + const calls = []; + const httpRequest = async (options) => { + calls.push(options); + return response; + }; + + httpRequest.calls = calls; + + return { + getInputData: () => [ + { + json: input, + }, + ], + getCredentials: async () => ({ + apiKey: "test-api-key", + baseUrl: "https://aport.io/", + }), + getNodeParameter: (name, _itemIndex, defaultValue) => { + if (Object.prototype.hasOwnProperty.call(parameters, name)) { + return parameters[name]; + } + return defaultValue; + }, + helpers: { + httpRequest, + }, + }; +} + +describe("APortVerify node", () => { + it("declares three routing outputs", () => { + const node = new APortVerify(); + + assert.deepEqual(node.description.outputs, ["main", "main", "main"]); + assert.deepEqual(node.description.outputNames, [ + "Approved", + "Denied", + "Error", + ]); + }); + + it("routes approved verification responses to the first output", async () => { + const node = new APortVerify(); + const context = createExecutionContext({ + input: { + agent_id: "agt_test_123", + amount: 42, + }, + parameters: { + passportId: "agt_test_123", + policyPack: "finance.payment.refund.v1", + contextMode: "input", + includeInputData: true, + }, + response: { + verified: true, + }, + }); + + const [approved, denied, errors] = await node.execute.call(context); + + assert.equal(approved.length, 1); + assert.equal(denied.length, 0); + assert.equal(errors.length, 0); + assert.equal(approved[0].json.aport.approved, true); + assert.equal(context.helpers.httpRequest.calls[0].method, "POST"); + assert.equal( + context.helpers.httpRequest.calls[0].url, + "https://aport.io/api/verify/policy/finance.payment.refund.v1" + ); + }); + + it("routes denied verification responses to the second output", async () => { + const node = new APortVerify(); + const context = createExecutionContext({ + parameters: { + passportId: "agt_test_123", + policyPack: "finance.payment.refund.v1", + contextMode: "json", + contextJson: "{\"amount\":42}", + includeInputData: false, + }, + response: { + decision: "deny", + }, + }); + + const [approved, denied, errors] = await node.execute.call(context); + + assert.equal(approved.length, 0); + assert.equal(denied.length, 1); + assert.equal(errors.length, 0); + assert.deepEqual(denied[0].json, { + aport: { + approved: false, + passportId: "agt_test_123", + policyPack: "finance.payment.refund.v1", + response: { + decision: "deny", + }, + }, + }); + }); + + it("routes node configuration errors to the third output", async () => { + const node = new APortVerify(); + const context = createExecutionContext({ + input: { + action: "refund", + }, + parameters: { + passportId: "", + policyPack: "finance.payment.refund.v1", + includeInputData: true, + }, + response: {}, + }); + + const [approved, denied, errors] = await node.execute.call(context); + + assert.equal(approved.length, 0); + assert.equal(denied.length, 0); + assert.equal(errors.length, 1); + assert.equal(errors[0].json.aport.error, "Passport ID is required."); + }); +}); diff --git a/examples/agent-frameworks/n8n/tests/utils.test.js b/examples/agent-frameworks/n8n/tests/utils.test.js new file mode 100644 index 0000000..5f1a8ce --- /dev/null +++ b/examples/agent-frameworks/n8n/tests/utils.test.js @@ -0,0 +1,96 @@ +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); +const { + buildContext, + isApproved, + normalizeBaseUrl, + parseContextValue, +} = require("../nodes/APortVerify/utils"); + +describe("APort Verify utilities", () => { + it("normalizes trailing slashes from base URLs", () => { + assert.equal(normalizeBaseUrl("https://aport.io///"), "https://aport.io"); + }); + + it("builds context from input JSON", () => { + assert.deepEqual( + buildContext({ + mode: "input", + inputJson: { + amount: 25, + action: "refund", + }, + }), + { + amount: 25, + action: "refund", + } + ); + }); + + it("builds context from JSON object configuration", () => { + assert.deepEqual( + buildContext({ + mode: "json", + contextJson: "{\"amount\":25,\"action\":\"refund\"}", + }), + { + amount: 25, + action: "refund", + } + ); + }); + + it("accepts pre-parsed JSON object configuration", () => { + assert.deepEqual( + buildContext({ + mode: "json", + contextJson: { + amount: 25, + action: "refund", + }, + }), + { + amount: 25, + action: "refund", + } + ); + }); + + it("builds context from key-value fields and parses JSON-looking values", () => { + assert.deepEqual( + buildContext({ + mode: "fields", + fields: { + field: [ + { + key: "amount", + value: "25", + }, + { + key: "metadata", + value: "{\"source\":\"n8n\"}", + }, + ], + }, + }), + { + amount: 25, + metadata: { + source: "n8n", + }, + } + ); + }); + + it("keeps plain strings as strings", () => { + assert.equal(parseContextValue("refund"), "refund"); + }); + + it("detects approved responses from common APort shapes", () => { + assert.equal(isApproved({ verified: true }), true); + assert.equal(isApproved({ decision: "allow" }), true); + assert.equal(isApproved({ status: "approved" }), true); + assert.equal(isApproved({ decision: "deny" }), false); + }); +}); diff --git a/examples/agent-frameworks/n8n/workflows/aport-verify-routing.json b/examples/agent-frameworks/n8n/workflows/aport-verify-routing.json new file mode 100644 index 0000000..c57ad2b --- /dev/null +++ b/examples/agent-frameworks/n8n/workflows/aport-verify-routing.json @@ -0,0 +1,98 @@ +{ + "name": "APort verification routing example", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 240, + 300 + ] + }, + { + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "agent-id", + "name": "agent_id", + "value": "agt_inst_demo_123", + "type": "string" + }, + { + "id": "action", + "name": "action", + "value": "refund", + "type": "string" + }, + { + "id": "amount", + "name": "amount", + "value": 25, + "type": "number" + } + ] + } + }, + "id": "sample-context", + "name": "Sample Context", + "type": "n8n-nodes-base.set", + "typeVersion": 3, + "position": [ + 460, + 300 + ] + }, + { + "parameters": { + "passportId": "={{$json.agent_id}}", + "policyPack": "finance.payment.refund.v1", + "contextMode": "input", + "includeInputData": true + }, + "id": "aport-verify", + "name": "APort Verify", + "type": "n8n-nodes-aport.aportVerify", + "typeVersion": 1, + "position": [ + 700, + 300 + ] + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Sample Context", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sample Context": { + "main": [ + [ + { + "node": "APort Verify", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "settings": { + "executionOrder": "v1" + }, + "staticData": null, + "tags": [] +}