From 4e8b5dee069931b58cd5fec34450fa1608fddbd9 Mon Sep 17 00:00:00 2001 From: dev8967 Date: Mon, 22 Jun 2026 21:31:10 +0800 Subject: [PATCH] feat: add Hono framework verification example --- examples/frameworks/hono/.env.example | 3 + examples/frameworks/hono/README.md | 114 +++++++++++ examples/frameworks/hono/examples/server.js | 68 +++++++ examples/frameworks/hono/package-lock.json | 41 ++++ examples/frameworks/hono/package.json | 27 +++ examples/frameworks/hono/src/index.js | 123 ++++++++++++ .../frameworks/hono/tests/middleware.test.js | 184 ++++++++++++++++++ 7 files changed, 560 insertions(+) create mode 100644 examples/frameworks/hono/.env.example create mode 100644 examples/frameworks/hono/README.md create mode 100644 examples/frameworks/hono/examples/server.js create mode 100644 examples/frameworks/hono/package-lock.json create mode 100644 examples/frameworks/hono/package.json create mode 100644 examples/frameworks/hono/src/index.js create mode 100644 examples/frameworks/hono/tests/middleware.test.js diff --git a/examples/frameworks/hono/.env.example b/examples/frameworks/hono/.env.example new file mode 100644 index 0000000..5c12602 --- /dev/null +++ b/examples/frameworks/hono/.env.example @@ -0,0 +1,3 @@ +APORT_API_KEY=your_api_key_here +APORT_BASE_URL=https://api.aport.io +PORT=3000 diff --git a/examples/frameworks/hono/README.md b/examples/frameworks/hono/README.md new file mode 100644 index 0000000..114dbd0 --- /dev/null +++ b/examples/frameworks/hono/README.md @@ -0,0 +1,114 @@ +# APort Hono Framework Example + +Runnable APort policy verification for [Hono](https://hono.dev/) applications. The example protects refund and admin routes while keeping the public route open. + +## What This Shows + +- Hono middleware that verifies an agent before a route handler runs +- Agent ID extraction from headers, query parameters, or JSON body +- Request context passed into the policy verification call +- Fail-closed strict mode with an optional non-strict mode for demos +- Unit tests that run without real APort credentials by injecting a mock client + +## Quick Start + +```bash +cd examples/frameworks/hono +npm install +npm test +npm start +``` + +The server starts on `http://localhost:3000`. + +## Try It + +Public route: + +```bash +curl http://localhost:3000/ +``` + +Protected refund route: + +```bash +curl -X POST http://localhost:3000/refund \ + -H "content-type: application/json" \ + -H "x-agent-id: agt_demo_refund_bot" \ + -d '{"amount":100}' +``` + +Protected admin route: + +```bash +curl http://localhost:3000/admin \ + -H "x-agent-id: agt_demo_admin_bot" +``` + +## Integration Highlight + +Add APort verification to Hono routes by creating a middleware factory and applying a policy pack before the route handler: + +```javascript +import { Hono } from "hono"; +import { createAPortHonoMiddleware } from "./src/index.js"; + +const app = new Hono(); +const aport = createAPortHonoMiddleware({ + apiKey: process.env.APORT_API_KEY, +}); + +app.post("/refund", aport("finance.payment.refund.v1"), c => { + const aportResult = c.get("aport"); + return c.json({ success: true, agentId: aportResult.agentId }); +}); +``` + +## Using The Real SDK + +This example ships with a mock client so tests and demos work out of the box. In production, inject a client backed by `@aporthq/sdk-node`: + +```javascript +import { APortClient } from "@aporthq/sdk-node"; +import { createAPortHonoMiddleware } from "./src/index.js"; + +const aportClient = new APortClient({ + apiKey: process.env.APORT_API_KEY, + baseUrl: process.env.APORT_BASE_URL, +}); + +const aport = createAPortHonoMiddleware({ client: aportClient }); +``` + +Your injected client only needs a compatible `verify(policy, agentId, context)` method. + +## Agent ID Sources + +The middleware checks these sources in order: + +1. `X-Agent-ID` header +2. `X-APort-Agent-ID` header +3. `agent_id` query parameter +4. `agent_id` JSON body field +5. `agentId` JSON body field +6. Static `agentId` middleware option + +## Configuration + +```bash +cp .env.example .env +``` + +Environment variables: + +- `APORT_API_KEY`: APort API key for a real client +- `APORT_BASE_URL`: Optional APort API base URL +- `PORT`: Local server port + +## Tests + +```bash +npm test +``` + +The test suite covers successful verification, missing agent IDs, denied agents, strict fail-closed behavior, non-strict fallback behavior, and agent ID extraction. diff --git a/examples/frameworks/hono/examples/server.js b/examples/frameworks/hono/examples/server.js new file mode 100644 index 0000000..9de8eba --- /dev/null +++ b/examples/frameworks/hono/examples/server.js @@ -0,0 +1,68 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; + +import { createAPortHonoMiddleware } from "../src/index.js"; + +const app = new Hono(); +const aport = createAPortHonoMiddleware({ + apiKey: process.env.APORT_API_KEY, + baseUrl: process.env.APORT_BASE_URL, +}); + +app.get("/", c => + c.json({ + message: "APort Hono example", + routes: { + refund: "POST /refund", + admin: "GET /admin", + }, + }), +); + +app.post("/refund", aport("finance.payment.refund.v1"), c => { + const body = c.get("aportBody") || {}; + const aportResult = c.get("aport"); + const maxRefund = aportResult.passport?.limits?.refund_amount_max_per_tx ?? 0; + + if (Number(body.amount || 0) > maxRefund) { + return c.json( + { + error: "Refund exceeds policy limit", + requested: body.amount, + limit: maxRefund, + }, + 403, + ); + } + + return c.json({ + success: true, + agentId: aportResult.agentId, + policy: aportResult.policy, + amount: body.amount, + }); +}); + +app.get( + "/admin", + aport("admin.access", { + context: { endpoint: "admin" }, + }), + c => { + const aportResult = c.get("aport"); + return c.json({ + success: true, + agentId: aportResult.agentId, + capabilities: aportResult.passport?.capabilities || [], + }); + }, +); + +const port = Number(process.env.PORT || 3000); + +serve({ + fetch: app.fetch, + port, +}); + +console.log(`APort Hono example listening on http://localhost:${port}`); diff --git a/examples/frameworks/hono/package-lock.json b/examples/frameworks/hono/package-lock.json new file mode 100644 index 0000000..788b9f2 --- /dev/null +++ b/examples/frameworks/hono/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "aport-hono-framework-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aport-hono-framework-example", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.13.8", + "hono": "^4.6.16" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/hono": { + "version": "4.12.26", + "resolved": "https://registry.npmmirror.com/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + } + } +} diff --git a/examples/frameworks/hono/package.json b/examples/frameworks/hono/package.json new file mode 100644 index 0000000..97d4eb1 --- /dev/null +++ b/examples/frameworks/hono/package.json @@ -0,0 +1,27 @@ +{ + "name": "aport-hono-framework-example", + "version": "1.0.0", + "description": "Runnable APort verification example for Hono applications", + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node examples/server.js", + "test": "node --test" + }, + "keywords": [ + "aport", + "hono", + "middleware", + "agent-verification", + "policy" + ], + "author": "APort Community", + "license": "MIT", + "dependencies": { + "hono": "^4.6.16", + "@hono/node-server": "^1.13.8" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/frameworks/hono/src/index.js b/examples/frameworks/hono/src/index.js new file mode 100644 index 0000000..2d862bb --- /dev/null +++ b/examples/frameworks/hono/src/index.js @@ -0,0 +1,123 @@ +class MockAPortClient { + constructor(options = {}) { + this.apiKey = options.apiKey || process.env.APORT_API_KEY; + this.baseUrl = options.baseUrl || process.env.APORT_BASE_URL || "https://api.aport.io"; + } + + async verify(policy, agentId, context = {}) { + return { + verified: true, + passport: { + agentId, + capabilities: ["finance.payment.refund.v1"], + limits: { refund_amount_max_per_tx: 1000 }, + }, + policy, + context, + message: "Mock verification successful", + }; + } +} + +async function readJsonBody(c) { + const contentType = c.req.header("content-type") || ""; + if (!contentType.includes("application/json")) { + return {}; + } + + try { + return await c.req.json(); + } catch { + return {}; + } +} + +function extractAgentId(c, options = {}) { + const body = c.get("aportBody") || {}; + const candidates = [ + c.req.header("x-agent-id"), + c.req.header("x-aport-agent-id"), + c.req.query("agent_id"), + body.agent_id, + body.agentId, + options.agentId, + ]; + + return candidates.find(value => typeof value === "string" && value.length > 0) || null; +} + +function createAPortHonoMiddleware(options = {}) { + const client = + options.client || + new MockAPortClient({ + apiKey: options.apiKey, + baseUrl: options.baseUrl, + }); + + return function requirePolicy(policy, middlewareOptions = {}) { + return async function aportPolicyMiddleware(c, next) { + const body = await readJsonBody(c); + c.set("aportBody", body); + + const agentId = extractAgentId(c, middlewareOptions); + if (!agentId) { + return c.json( + { + error: "Agent ID required", + message: "Provide X-Agent-ID, X-APort-Agent-ID, agent_id, or agentId.", + }, + 400, + ); + } + + try { + const result = await client.verify(policy, agentId, { + method: c.req.method, + path: new URL(c.req.url).pathname, + ...middlewareOptions.context, + }); + + if (!result.verified) { + return c.json( + { + error: "Verification failed", + message: result.message || "Agent verification failed", + details: result.details, + }, + 403, + ); + } + + c.set("aport", { + verified: true, + agentId, + passport: result.passport, + policy, + result, + }); + + return next(); + } catch (error) { + if (middlewareOptions.strict === false) { + c.set("aport", { + verified: false, + agentId, + policy, + error: error.message, + }); + return next(); + } + + return c.json( + { + error: "Verification error", + message: "Internal verification error", + }, + 500, + ); + } + }; + }; +} + +export { MockAPortClient, createAPortHonoMiddleware, extractAgentId }; diff --git a/examples/frameworks/hono/tests/middleware.test.js b/examples/frameworks/hono/tests/middleware.test.js new file mode 100644 index 0000000..0bc611d --- /dev/null +++ b/examples/frameworks/hono/tests/middleware.test.js @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { Hono } from "hono"; + +import { createAPortHonoMiddleware } from "../src/index.js"; + +function makeApp(client, middlewareOptions = {}) { + const app = new Hono(); + const aport = createAPortHonoMiddleware({ client }); + + app.get("/public", c => c.json({ message: "public" })); + app.post("/refund", aport("finance.payment.refund.v1", middlewareOptions), c => { + const aportResult = c.get("aport"); + const body = c.get("aportBody"); + + return c.json({ + success: true, + agentId: aportResult.agentId, + amount: body.amount, + }); + }); + + return app; +} + +async function json(response) { + return response.json(); +} + +describe("APort Hono middleware", () => { + it("allows public routes without verification", async () => { + const app = makeApp({ verify: async () => ({ verified: true }) }); + + const response = await app.request("/public"); + assert.equal(response.status, 200); + assert.deepEqual(await json(response), { message: "public" }); + }); + + it("verifies an agent from the X-Agent-ID header", async () => { + const calls = []; + const app = makeApp({ + verify: async (policy, agentId, context) => { + calls.push({ policy, agentId, context }); + return { + verified: true, + passport: { limits: { refund_amount_max_per_tx: 1000 } }, + }; + }, + }); + + const response = await app.request("/refund", { + method: "POST", + headers: { + "content-type": "application/json", + "x-agent-id": "agt_header_123", + }, + body: JSON.stringify({ amount: 100 }), + }); + + assert.equal(response.status, 200); + assert.equal((await json(response)).agentId, "agt_header_123"); + assert.equal(calls.length, 1); + assert.equal(calls[0].policy, "finance.payment.refund.v1"); + assert.equal(calls[0].agentId, "agt_header_123"); + assert.equal(calls[0].context.path, "/refund"); + }); + + it("extracts an agent ID from query parameters", async () => { + const calls = []; + const app = makeApp({ + verify: async (_policy, agentId) => { + calls.push(agentId); + return { verified: true, passport: {} }; + }, + }); + + const response = await app.request("/refund?agent_id=agt_query_123", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ amount: 100 }), + }); + + assert.equal(response.status, 200); + assert.equal(calls[0], "agt_query_123"); + }); + + it("extracts an agent ID from the JSON request body", async () => { + const calls = []; + const app = makeApp({ + verify: async (_policy, agentId) => { + calls.push(agentId); + return { verified: true, passport: {} }; + }, + }); + + const response = await app.request("/refund", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ amount: 100, agent_id: "agt_body_123" }), + }); + + assert.equal(response.status, 200); + assert.equal(calls[0], "agt_body_123"); + }); + + it("rejects requests without an agent ID", async () => { + const app = makeApp({ + verify: async () => { + throw new Error("should not verify without agent id"); + }, + }); + + const response = await app.request("/refund", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ amount: 100 }), + }); + + assert.equal(response.status, 400); + assert.equal((await json(response)).error, "Agent ID required"); + }); + + it("rejects unverified agents", async () => { + const app = makeApp({ + verify: async () => ({ verified: false, message: "Denied by policy" }), + }); + + const response = await app.request("/refund", { + method: "POST", + headers: { + "content-type": "application/json", + "x-agent-id": "agt_denied", + }, + body: JSON.stringify({ amount: 100 }), + }); + + assert.equal(response.status, 403); + assert.equal((await json(response)).message, "Denied by policy"); + }); + + it("fails closed when verification errors in strict mode", async () => { + const app = makeApp({ + verify: async () => { + throw new Error("APort API unavailable"); + }, + }); + + const response = await app.request("/refund", { + method: "POST", + headers: { + "content-type": "application/json", + "x-agent-id": "agt_error", + }, + body: JSON.stringify({ amount: 100 }), + }); + + assert.equal(response.status, 500); + assert.equal((await json(response)).error, "Verification error"); + }); + + it("can continue in non-strict mode after verification errors", async () => { + const app = makeApp( + { + verify: async () => { + throw new Error("APort API unavailable"); + }, + }, + { strict: false }, + ); + + const response = await app.request("/refund", { + method: "POST", + headers: { + "content-type": "application/json", + "x-agent-id": "agt_error", + }, + body: JSON.stringify({ amount: 100 }), + }); + + assert.equal(response.status, 200); + assert.equal((await json(response)).agentId, "agt_error"); + }); +});