From f8e3f1ad2e4867d013824d81cbc20c868fd832bb Mon Sep 17 00:00:00 2001 From: remo1-agent Date: Tue, 12 May 2026 15:15:45 +0900 Subject: [PATCH] fix: resolve bounty issue - feat: add n8n APort verification node --- README.md | 2 +- examples/agent-frameworks/n8n/README.md | 138 ++++++ .../n8n/credentials/AportApi.credentials.js | 41 ++ .../n8n/examples/aport-verify-workflow.json | 147 +++++++ .../n8n/nodes/Aport/Aport.node.js | 397 ++++++++++++++++++ .../n8n/nodes/Aport/aport.svg | 6 + examples/agent-frameworks/n8n/package.json | 35 ++ .../n8n/tests/aport-node.test.js | 207 +++++++++ 8 files changed, 972 insertions(+), 1 deletion(-) create mode 100644 examples/agent-frameworks/n8n/README.md create mode 100644 examples/agent-frameworks/n8n/credentials/AportApi.credentials.js create mode 100644 examples/agent-frameworks/n8n/examples/aport-verify-workflow.json create mode 100644 examples/agent-frameworks/n8n/nodes/Aport/Aport.node.js create mode 100644 examples/agent-frameworks/n8n/nodes/Aport/aport.svg create mode 100644 examples/agent-frameworks/n8n/package.json create mode 100644 examples/agent-frameworks/n8n/tests/aport-node.test.js diff --git a/README.md b/README.md index 77cbea9..2a844c1 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Make APort the default trust layer for AI agent frameworks. |-------------|-------------|--------|------------| | [LangChain Tool Guard](examples/agent-frameworks/langchain/) | Secure LangChain tools with APort verification | ✅ Active | Community | | [CrewAI Task Decorator](examples/agent-frameworks/crewai/) | `@aport_verify` decorator for CrewAI tasks | ✅ Active | Community | -| [n8n APort Node](examples/agent-frameworks/n8n/) | Custom n8n node for APort verification | 🚧 In Progress | Community | +| [n8n APort Node](examples/agent-frameworks/n8n/) | Custom n8n node for APort verification | ✅ Active | Community | | [LangGraph Checkpoints](examples/agent-frameworks/langgraph/) | APort verification in LangGraph state machines | 📋 Planned | Community | ### 🛒 **E-commerce Platform Guardrails** diff --git a/examples/agent-frameworks/n8n/README.md b/examples/agent-frameworks/n8n/README.md new file mode 100644 index 0000000..7065b68 --- /dev/null +++ b/examples/agent-frameworks/n8n/README.md @@ -0,0 +1,138 @@ +# n8n APort Node + +Custom n8n community node for verifying APort passports and policy packs inside visual workflows. + +The node is designed for automation flows that need to decide whether an AI agent, service account, or delegated workflow is allowed to continue. It accepts an APort agent/passport ID plus workflow context, calls APort verification endpoints, and returns route-friendly fields such as `aport_verified` and `aport_route`. + +## Features + +- APort API credential type with API key, base URL, and timeout settings +- `Verify Policy` operation for policy-pack checks +- `Get Passport` operation for retrieving passport details +- JSON or key/value context input +- Optional merge of incoming n8n item JSON into the verification context +- `throwOnDeny` mode for fail-fast workflows +- Route-friendly output for IF/Switch nodes +- Mocked unit tests that do not require a live APort API key + +## Install Locally + +From this directory: + +```bash +npm install +npm test +npm link +``` + +Then link it into your n8n custom node directory: + +```bash +mkdir -p ~/.n8n/custom +cd ~/.n8n/custom +npm link n8n-nodes-aport +n8n start +``` + +For Docker-based n8n, copy this folder into the image or mount it as a custom node package and run `npm install` from the package directory. + +## Configure Credentials + +Create an `APort API` credential in n8n: + +| Field | Description | +| --- | --- | +| API Key | Optional bearer token for authenticated APort requests | +| Base URL | APort API base URL, defaults to `https://aport.io` | +| Request Timeout | HTTP timeout in milliseconds | + +## Verify Policy Operation + +Recommended node settings: + +| Setting | Example | +| --- | --- | +| Operation | `Verify Policy` | +| Agent ID | `={{ $json.agent_id }}` | +| Policy Pack | `finance.payment.refund.v1` | +| Include Input JSON in Context | `true` | +| Context JSON | `{"source":"n8n","workflow":"refund-verification"}` | +| Throw on Deny | `false` | + +The node sends this payload shape to APort: + +```json +{ + "context": { + "agent_id": "agt_inst_demo_123", + "policy_id": "finance.payment.refund.v1", + "context": { + "order_id": "order_1001", + "amount": 149.99, + "source": "n8n" + } + } +} +``` + +## Output + +Successful and denied checks both produce one output item unless `Throw on Deny` is enabled. + +```json +{ + "order_id": "order_1001", + "amount": 149.99, + "aport_verified": true, + "aport_route": "verified", + "aport": { + "verified": true, + "route": "verified", + "passport": { + "agent_id": "agt_inst_demo_123" + }, + "policy": "finance.payment.refund.v1", + "message": "Policy verified" + } +} +``` + +Use `aport_route` in an IF or Switch node: + +- `verified`: continue the trusted branch +- `denied`: route to manual review, rejection, alerting, or a fallback workflow + +## Example Workflow + +Import [`examples/aport-verify-workflow.json`](examples/aport-verify-workflow.json) into n8n. It creates: + +1. Manual trigger +2. Sample refund context +3. APort policy verification +4. IF routing based on `aport_route` + +Replace the credential placeholder with your local APort credential before running. + +## Development + +```bash +npm test +``` + +The tests bind the node to a mocked n8n execution context, capture HTTP requests, and verify: + +- credential headers and normalized base URL +- APort verification payload shape +- JSON and key/value context modes +- denied verification behavior +- passport retrieval behavior + +## Publishing Checklist + +Before publishing to npm: + +1. Confirm the package name starts with `n8n-nodes-`. +2. Keep the `n8n` package metadata pointing at all node and credential files. +3. Run `npm test`. +4. Test local install with `npm link`. +5. Import the example workflow and verify the node appears in n8n. 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..c659927 --- /dev/null +++ b/examples/agent-frameworks/n8n/credentials/AportApi.credentials.js @@ -0,0 +1,41 @@ +"use strict"; + +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: + "APort API key. Some verification endpoints can be used without a key, but authenticated requests should provide one.", + }, + { + displayName: "Base URL", + name: "baseUrl", + type: "string", + default: "https://aport.io", + required: true, + description: "Base URL for the APort API.", + }, + { + displayName: "Request Timeout (ms)", + name: "timeout", + type: "number", + default: 10000, + required: true, + description: "Maximum time to wait for APort API responses.", + }, + ]; + } +} + +module.exports = { AportApi }; diff --git a/examples/agent-frameworks/n8n/examples/aport-verify-workflow.json b/examples/agent-frameworks/n8n/examples/aport-verify-workflow.json new file mode 100644 index 0000000..1c4bb95 --- /dev/null +++ b/examples/agent-frameworks/n8n/examples/aport-verify-workflow.json @@ -0,0 +1,147 @@ +{ + "name": "APort refund verification example", + "nodes": [ + { + "parameters": {}, + "id": "manual-trigger", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 240, + 280 + ] + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "agent-id", + "name": "agent_id", + "value": "agt_inst_demo_123", + "type": "string" + }, + { + "id": "order-id", + "name": "order_id", + "value": "order_1001", + "type": "string" + }, + { + "id": "refund-amount", + "name": "amount", + "value": 149.99, + "type": "number" + } + ] + }, + "options": {} + }, + "id": "sample-refund", + "name": "Sample Refund Context", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 500, + 280 + ] + }, + { + "parameters": { + "operation": "verifyPolicy", + "agentId": "={{ $json.agent_id }}", + "policyId": "finance.payment.refund.v1", + "includeInputJson": true, + "contextMode": "json", + "contextJson": "{\"source\":\"n8n\",\"workflow\":\"refund-verification\"}", + "throwOnDeny": false, + "includeRawResponse": true + }, + "id": "aport-verify", + "name": "APort Verify Refund", + "type": "n8n-nodes-aport.aport", + "typeVersion": 1, + "position": [ + 760, + 280 + ], + "credentials": { + "aportApi": { + "id": "replace-me", + "name": "APort API" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "aport-verified-route", + "leftValue": "={{ $json.aport_route }}", + "rightValue": "verified", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "route-by-result", + "name": "Route by Verification", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1020, + 280 + ] + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Sample Refund Context", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sample Refund Context": { + "main": [ + [ + { + "node": "APort Verify Refund", + "type": "main", + "index": 0 + } + ] + ] + }, + "APort Verify Refund": { + "main": [ + [ + { + "node": "Route by Verification", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/examples/agent-frameworks/n8n/nodes/Aport/Aport.node.js b/examples/agent-frameworks/n8n/nodes/Aport/Aport.node.js new file mode 100644 index 0000000..1e3a58b --- /dev/null +++ b/examples/agent-frameworks/n8n/nodes/Aport/Aport.node.js @@ -0,0 +1,397 @@ +"use strict"; + +const DEFAULT_BASE_URL = "https://aport.io"; +const DEFAULT_POLICY_ID = "code.repository.merge.v1"; + +function normalizeBaseUrl(baseUrl) { + const value = String(baseUrl || DEFAULT_BASE_URL).trim(); + return value.replace(/\/+$/, ""); +} + +function parseJsonObject(rawValue, fieldName) { + if (rawValue === undefined || rawValue === null || rawValue === "") { + return {}; + } + + if (typeof rawValue === "object" && !Array.isArray(rawValue)) { + return rawValue; + } + + try { + const parsed = JSON.parse(String(rawValue)); + if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error(`${fieldName} must be a JSON object`); + } + return parsed; + } catch (error) { + throw new Error(`Invalid ${fieldName}: ${error.message}`); + } +} + +function parseKeyValueContext(pairs = {}) { + const values = pairs.values || []; + return values.reduce((context, pair) => { + if (!pair.key) { + return context; + } + + context[pair.key] = coerceValue(pair.value); + return context; + }, {}); +} + +function coerceValue(value) { + if (value === "true") return true; + if (value === "false") return false; + if (value === "null") return null; + if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) { + return Number(value); + } + return value; +} + +function buildContext(nodeContext, itemIndex, item) { + const contextMode = nodeContext.getNodeParameter("contextMode", itemIndex); + const itemContext = nodeContext.getNodeParameter("includeInputJson", itemIndex) + ? item.json + : {}; + + if (contextMode === "keyValue") { + return { + ...itemContext, + ...parseKeyValueContext(nodeContext.getNodeParameter("contextPairs", itemIndex)), + }; + } + + return { + ...itemContext, + ...parseJsonObject(nodeContext.getNodeParameter("contextJson", itemIndex), "Context JSON"), + }; +} + +function buildVerifyPayload(agentId, policyId, context) { + return { + context: { + agent_id: agentId, + policy_id: policyId, + context, + }, + }; +} + +function isVerified(response) { + return Boolean( + response && + (response.verified === true || + response.allowed === true || + response.authorized === true || + response.decision === "allow") + ); +} + +function toOutputItem(inputItem, response, options = {}) { + const verified = isVerified(response); + const route = verified ? "verified" : "denied"; + + const aport = options.includeRawResponse + ? response + : { + verified, + route, + passport: response && response.passport, + policy: response && (response.policy || response.policy_id), + message: response && (response.message || response.reason), + }; + + return { + json: { + ...inputItem.json, + aport_verified: verified, + aport_route: route, + aport, + }, + pairedItem: { + item: options.itemIndex, + }, + }; +} + +async function requestAport(nodeContext, credentials, requestOptions) { + const baseUrl = normalizeBaseUrl(credentials.baseUrl); + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + }; + + if (credentials.apiKey) { + headers.Authorization = `Bearer ${credentials.apiKey}`; + } + + return nodeContext.helpers.httpRequest({ + method: requestOptions.method, + url: `${baseUrl}${requestOptions.path}`, + headers, + body: requestOptions.body, + qs: requestOptions.qs, + json: true, + timeout: Number(credentials.timeout || 10000), + }); +} + +class Aport { + constructor() { + this.description = { + displayName: "APort", + name: "aport", + icon: "file:aport.svg", + group: ["transform"], + version: 1, + description: "Verify APort passports and policy checks inside n8n workflows", + defaults: { + name: "APort Verify", + }, + inputs: ["main"], + outputs: ["main"], + credentials: [ + { + name: "aportApi", + required: false, + }, + ], + properties: [ + { + displayName: "Operation", + name: "operation", + type: "options", + noDataExpression: true, + options: [ + { + name: "Verify Policy", + value: "verifyPolicy", + description: "Verify an APort passport against a policy pack", + action: "Verify an agent passport against an APort policy", + }, + { + name: "Get Passport", + value: "getPassport", + description: "Fetch passport details for an APort agent", + action: "Get an APort passport", + }, + ], + default: "verifyPolicy", + }, + { + displayName: "Agent ID", + name: "agentId", + type: "string", + required: true, + default: "={{ $json.agent_id || $json.agentId || $json.passport_id || $json.passportId }}", + description: "APort agent or passport identifier to verify.", + }, + { + displayName: "Policy Pack", + name: "policyId", + type: "string", + required: true, + default: DEFAULT_POLICY_ID, + displayOptions: { + show: { + operation: ["verifyPolicy"], + }, + }, + description: "APort policy pack identifier, for example finance.payment.refund.v1.", + }, + { + displayName: "Include Input JSON in Context", + name: "includeInputJson", + type: "boolean", + default: true, + displayOptions: { + show: { + operation: ["verifyPolicy"], + }, + }, + description: + "Whether to merge the incoming item JSON into the APort verification context.", + }, + { + displayName: "Context Mode", + name: "contextMode", + type: "options", + noDataExpression: true, + displayOptions: { + show: { + operation: ["verifyPolicy"], + }, + }, + options: [ + { + name: "JSON", + value: "json", + }, + { + name: "Key/Value", + value: "keyValue", + }, + ], + default: "json", + description: "How to provide extra verification context.", + }, + { + displayName: "Context JSON", + name: "contextJson", + type: "json", + default: "{}", + displayOptions: { + show: { + operation: ["verifyPolicy"], + contextMode: ["json"], + }, + }, + description: "Additional context object sent to APort.", + }, + { + displayName: "Context Fields", + name: "contextPairs", + type: "fixedCollection", + typeOptions: { + multipleValues: true, + }, + default: {}, + placeholder: "Add Field", + displayOptions: { + show: { + operation: ["verifyPolicy"], + contextMode: ["keyValue"], + }, + }, + options: [ + { + displayName: "Values", + name: "values", + values: [ + { + displayName: "Key", + name: "key", + type: "string", + default: "", + }, + { + displayName: "Value", + name: "value", + type: "string", + default: "", + }, + ], + }, + ], + }, + { + displayName: "Throw on Deny", + name: "throwOnDeny", + type: "boolean", + default: false, + displayOptions: { + show: { + operation: ["verifyPolicy"], + }, + }, + description: + "Whether to fail the node when APort denies verification instead of returning a denied route.", + }, + { + displayName: "Include Raw APort Response", + name: "includeRawResponse", + type: "boolean", + default: false, + description: "Whether to include the full APort response in output.", + }, + ], + }; + } + + async execute() { + const items = this.getInputData(); + const credentials = await this.getCredentials("aportApi").catch(() => ({})); + const returnData = []; + + for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { + try { + const operation = this.getNodeParameter("operation", itemIndex); + const agentId = this.getNodeParameter("agentId", itemIndex); + + if (!agentId || typeof agentId !== "string") { + throw new Error("Agent ID is required"); + } + + if (operation === "getPassport") { + const response = await requestAport(this, credentials, { + method: "GET", + path: `/api/verify/${encodeURIComponent(agentId)}`, + qs: { + policy_pack: DEFAULT_POLICY_ID, + context: "{}", + }, + }); + + returnData.push({ + json: { + ...items[itemIndex].json, + aport_passport: response, + }, + pairedItem: { + item: itemIndex, + }, + }); + continue; + } + + const policyId = this.getNodeParameter("policyId", itemIndex); + const context = buildContext(this, itemIndex, items[itemIndex]); + const response = await requestAport(this, credentials, { + method: "POST", + path: `/api/verify/policy/${encodeURIComponent(policyId)}`, + body: buildVerifyPayload(agentId, policyId, context), + }); + + if (!isVerified(response) && this.getNodeParameter("throwOnDeny", itemIndex)) { + throw new Error(response.message || response.reason || "APort verification denied"); + } + + returnData.push( + toOutputItem(items[itemIndex], response, { + includeRawResponse: this.getNodeParameter("includeRawResponse", itemIndex), + itemIndex, + }) + ); + } catch (error) { + if (this.continueOnFail && this.continueOnFail()) { + returnData.push({ + json: { + ...items[itemIndex].json, + aport_error: error.message, + }, + pairedItem: { + item: itemIndex, + }, + }); + continue; + } + + throw error; + } + } + + return [returnData]; + } +} + +module.exports = { + Aport, + buildVerifyPayload, + coerceValue, + isVerified, + normalizeBaseUrl, + parseJsonObject, + toOutputItem, +}; diff --git a/examples/agent-frameworks/n8n/nodes/Aport/aport.svg b/examples/agent-frameworks/n8n/nodes/Aport/aport.svg new file mode 100644 index 0000000..d3dbded --- /dev/null +++ b/examples/agent-frameworks/n8n/nodes/Aport/aport.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/examples/agent-frameworks/n8n/package.json b/examples/agent-frameworks/n8n/package.json new file mode 100644 index 0000000..6f6f16f --- /dev/null +++ b/examples/agent-frameworks/n8n/package.json @@ -0,0 +1,35 @@ +{ + "name": "n8n-nodes-aport", + "version": "0.1.0", + "description": "n8n community node for APort passport verification and policy checks", + "main": "nodes/Aport/Aport.node.js", + "scripts": { + "test": "node --test tests/*.test.js" + }, + "keywords": [ + "n8n-community-node-package", + "n8n", + "aport", + "passport", + "policy", + "verification" + ], + "author": "APort Community", + "license": "MIT", + "engines": { + "node": ">=18.10" + }, + "n8n": { + "n8nNodesApiVersion": 1, + "credentials": [ + "credentials/AportApi.credentials.js" + ], + "nodes": [ + "nodes/Aport/Aport.node.js" + ] + }, + "peerDependencies": { + "n8n-workflow": "*" + }, + "devDependencies": {} +} diff --git a/examples/agent-frameworks/n8n/tests/aport-node.test.js b/examples/agent-frameworks/n8n/tests/aport-node.test.js new file mode 100644 index 0000000..9e84399 --- /dev/null +++ b/examples/agent-frameworks/n8n/tests/aport-node.test.js @@ -0,0 +1,207 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + Aport, + buildVerifyPayload, + coerceValue, + isVerified, + normalizeBaseUrl, + parseJsonObject, + toOutputItem, +} = require("../nodes/Aport/Aport.node"); + +function createRuntime({ + params, + items = [{ json: { order_id: "ord_123", amount: 42 } }], + credentials = { baseUrl: "https://aport.test/", apiKey: "test-key", timeout: 2500 }, + response = { verified: true, passport: { agent_id: "agt_123" }, message: "ok" }, + continueOnFail = false, +}) { + const requests = []; + + return { + runtime: { + getInputData() { + return items; + }, + getNodeParameter(name) { + if (Object.prototype.hasOwnProperty.call(params, name)) { + return params[name]; + } + throw new Error(`Missing node parameter: ${name}`); + }, + async getCredentials(name) { + assert.equal(name, "aportApi"); + return credentials; + }, + continueOnFail() { + return continueOnFail; + }, + helpers: { + async httpRequest(options) { + requests.push(options); + return response; + }, + }, + }, + requests, + }; +} + +test("normalizes APort base URLs", () => { + assert.equal(normalizeBaseUrl("https://aport.io/"), "https://aport.io"); + assert.equal(normalizeBaseUrl("https://aport.io///"), "https://aport.io"); + assert.equal(normalizeBaseUrl(undefined), "https://aport.io"); +}); + +test("parses JSON context objects and rejects invalid shapes", () => { + assert.deepEqual(parseJsonObject('{"amount":100}', "Context JSON"), { amount: 100 }); + assert.deepEqual(parseJsonObject("", "Context JSON"), {}); + assert.throws(() => parseJsonObject("[1,2]", "Context JSON"), /must be a JSON object/); + assert.throws(() => parseJsonObject("{bad", "Context JSON"), /Invalid Context JSON/); +}); + +test("coerces key/value context field strings", () => { + assert.equal(coerceValue("true"), true); + assert.equal(coerceValue("false"), false); + assert.equal(coerceValue("null"), null); + assert.equal(coerceValue("12.5"), 12.5); + assert.equal(coerceValue("agt_123"), "agt_123"); +}); + +test("builds the policy verification payload expected by APort", () => { + assert.deepEqual( + buildVerifyPayload("agt_123", "finance.payment.refund.v1", { amount: 42 }), + { + context: { + agent_id: "agt_123", + policy_id: "finance.payment.refund.v1", + context: { amount: 42 }, + }, + } + ); +}); + +test("detects allowed APort responses across compatible response names", () => { + assert.equal(isVerified({ verified: true }), true); + assert.equal(isVerified({ allowed: true }), true); + assert.equal(isVerified({ authorized: true }), true); + assert.equal(isVerified({ decision: "allow" }), true); + assert.equal(isVerified({ verified: false }), false); +}); + +test("formats verification output with route fields", () => { + const output = toOutputItem( + { json: { order_id: "ord_123" } }, + { verified: false, message: "policy denied" }, + { itemIndex: 0 } + ); + + assert.equal(output.json.order_id, "ord_123"); + assert.equal(output.json.aport_verified, false); + assert.equal(output.json.aport_route, "denied"); + assert.equal(output.json.aport.message, "policy denied"); + assert.deepEqual(output.pairedItem, { item: 0 }); +}); + +test("executes policy verification through n8n httpRequest helper", async () => { + const node = new Aport(); + const { runtime, requests } = createRuntime({ + params: { + operation: "verifyPolicy", + agentId: "agt_123", + policyId: "finance.payment.refund.v1", + includeInputJson: true, + contextMode: "json", + contextJson: '{"currency":"USD"}', + throwOnDeny: false, + includeRawResponse: false, + }, + }); + + const [result] = await node.execute.call(runtime); + + assert.equal(result[0].json.aport_verified, true); + assert.equal(result[0].json.aport_route, "verified"); + assert.equal(requests[0].method, "POST"); + assert.equal( + requests[0].url, + "https://aport.test/api/verify/policy/finance.payment.refund.v1" + ); + assert.equal(requests[0].headers.Authorization, "Bearer test-key"); + assert.equal(requests[0].timeout, 2500); + assert.deepEqual(requests[0].body.context.context, { + order_id: "ord_123", + amount: 42, + currency: "USD", + }); +}); + +test("uses key/value context fields when selected", async () => { + const node = new Aport(); + const { runtime, requests } = createRuntime({ + params: { + operation: "verifyPolicy", + agentId: "agt_123", + policyId: "finance.payment.refund.v1", + includeInputJson: false, + contextMode: "keyValue", + contextPairs: { + values: [ + { key: "amount", value: "100" }, + { key: "requires_review", value: "true" }, + ], + }, + throwOnDeny: false, + includeRawResponse: true, + }, + }); + + const [result] = await node.execute.call(runtime); + + assert.equal(result[0].json.aport_verified, true); + assert.deepEqual(requests[0].body.context.context, { + amount: 100, + requires_review: true, + }); +}); + +test("throws on denied verification when configured", async () => { + const node = new Aport(); + const { runtime } = createRuntime({ + params: { + operation: "verifyPolicy", + agentId: "agt_123", + policyId: "finance.payment.refund.v1", + includeInputJson: true, + contextMode: "json", + contextJson: "{}", + throwOnDeny: true, + includeRawResponse: false, + }, + response: { verified: false, message: "denied by policy" }, + }); + + await assert.rejects(() => node.execute.call(runtime), /denied by policy/); +}); + +test("gets passport details for an agent", async () => { + const node = new Aport(); + const { runtime, requests } = createRuntime({ + params: { + operation: "getPassport", + agentId: "agt_123", + includeRawResponse: false, + }, + response: { agent_id: "agt_123", capabilities: ["refund"] }, + }); + + const [result] = await node.execute.call(runtime); + + assert.equal(requests[0].method, "GET"); + assert.equal(requests[0].url, "https://aport.test/api/verify/agt_123"); + assert.equal(result[0].json.aport_passport.agent_id, "agt_123"); +});