diff --git a/README.md b/README.md index 77cbea9..759c439 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ Reduce APort integration time from hours to minutes. | Tool | Description | Status | Maintainer | |------|-------------|--------|------------| | [APort CLI](tools/cli/) | `npx create-aport-integration` scaffolding tool | ✅ Active | Community | +| [Discord Verification Bot](examples/developer-tools/discord-bot/) | Slash-command bot for team workflow verification | ✅ Active | Community | | [VS Code Extension](tools/vscode-extension/) | Policy development with IntelliSense | 🚧 In Progress | Community | | [Postman Collection](tools/postman-collection/) | Complete API testing collection | ✅ Active | Community | diff --git a/examples/developer-tools/discord-bot/.env.example b/examples/developer-tools/discord-bot/.env.example new file mode 100644 index 0000000..0531815 --- /dev/null +++ b/examples/developer-tools/discord-bot/.env.example @@ -0,0 +1,6 @@ +DISCORD_TOKEN=replace_with_your_bot_token +DISCORD_CLIENT_ID=replace_with_your_application_client_id +DISCORD_GUILD_ID=replace_with_your_test_server_id +APORT_API_KEY=your_aport_api_key +APORT_BASE_URL=https://aport.io +APORT_DEFAULT_POLICY=code.repository.merge.v1 diff --git a/examples/developer-tools/discord-bot/README.md b/examples/developer-tools/discord-bot/README.md new file mode 100644 index 0000000..21418aa --- /dev/null +++ b/examples/developer-tools/discord-bot/README.md @@ -0,0 +1,73 @@ +# APort Discord Verification Bot + +Discord bot for checking team workflow actions with APort policy verification. + +The bot adds slash commands that let a team verify whether an agent is allowed to perform actions such as merging a pull request, processing a refund, exporting data, or requesting admin access. + +## Commands + +| Command | Description | +| --- | --- | +| `/aport-verify` | Verify an agent action against an APort policy. | +| `/aport-help` | Show usage details and a context example. | + +`/aport-verify` options: + +| Option | Required | Description | +| --- | --- | --- | +| `agent-id` | Yes | APort agent id or team-mapped agent identifier. | +| `action` | Yes | Team workflow action to verify. | +| `policy` | No | APort policy pack. Defaults to `APORT_DEFAULT_POLICY`. | +| `context` | No | Additional JSON context for the policy check. | + +## Setup + +1. Create a Discord application in the Discord Developer Portal. +2. Add a bot user and copy the bot token. +3. Enable the `applications.commands` OAuth2 scope when installing the bot. +4. Copy `.env.example` to `.env` and fill in the values. + +```bash +npm install +cp .env.example .env +npm run register +npm start +``` + +For local development, set `DISCORD_GUILD_ID` so commands register immediately in one test server. Leave it empty when you want global commands. + +## Environment + +```bash +DISCORD_TOKEN=replace_with_your_bot_token +DISCORD_CLIENT_ID=replace_with_your_application_client_id +DISCORD_GUILD_ID=replace_with_your_test_server_id +APORT_API_KEY=your_aport_api_key +APORT_BASE_URL=https://aport.io +APORT_DEFAULT_POLICY=code.repository.merge.v1 +``` + +## Example + +```text +/aport-verify agent-id:ap_a2d10232c6534523812423eec8a1425c action:merge_pr policy:code.repository.merge.v1 context:{"repository":"owner/repo","pull_request":42} +``` + +The bot replies privately with an approval or denial embed. The request context sent to APort includes the Discord guild, channel, user, selected action, and any custom JSON context. + +## Development + +```bash +npm test +npm run check +``` + +## Files + +```text +src/index.js Discord client entrypoint +src/register-commands.js Slash command registration +src/commands.js Command definitions and handlers +src/aport.js APort policy verification helper +tests/ Unit tests for parsing, context, and API behavior +``` diff --git a/examples/developer-tools/discord-bot/package-lock.json b/examples/developer-tools/discord-bot/package-lock.json new file mode 100644 index 0000000..59afda3 --- /dev/null +++ b/examples/developer-tools/discord-bot/package-lock.json @@ -0,0 +1,341 @@ +{ + "name": "aport-discord-verifier", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aport-discord-verifier", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "discord.js": "^14.15.3", + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/examples/developer-tools/discord-bot/package.json b/examples/developer-tools/discord-bot/package.json new file mode 100644 index 0000000..a696cd2 --- /dev/null +++ b/examples/developer-tools/discord-bot/package.json @@ -0,0 +1,29 @@ +{ + "name": "aport-discord-verifier", + "version": "0.1.0", + "private": true, + "description": "Discord bot for verifying team workflow actions with APort", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "register": "node src/register-commands.js", + "test": "node --test", + "check": "node --check src/index.js && node --check src/register-commands.js && node --check src/aport.js && node --check src/commands.js" + }, + "keywords": [ + "aport", + "discord", + "bot", + "policy-verification", + "team-workflows" + ], + "author": "APort Community", + "license": "MIT", + "dependencies": { + "discord.js": "^14.15.3", + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/developer-tools/discord-bot/src/aport.js b/examples/developer-tools/discord-bot/src/aport.js new file mode 100644 index 0000000..9812b6a --- /dev/null +++ b/examples/developer-tools/discord-bot/src/aport.js @@ -0,0 +1,114 @@ +const DEFAULT_BASE_URL = "https://aport.io"; + +function formatReasons(reasons) { + if (!Array.isArray(reasons)) { + return undefined; + } + + return reasons + .map((reason) => { + if (!reason) { + return undefined; + } + + if (typeof reason === "string") { + return reason; + } + + return reason.message || reason.code; + }) + .filter(Boolean) + .join(", "); +} + +function normalizeDecision(result) { + const decision = result && (result.decision || (result.data && result.data.decision)); + + if (decision && Object.prototype.hasOwnProperty.call(decision, "allow")) { + return { + allowed: Boolean(decision.allow), + reason: formatReasons(decision.reasons) || decision.reason, + raw: result, + }; + } + + return { + allowed: Boolean(result && (result.verified || result.allowed || result.allow)), + reason: result && (result.reason || result.message), + raw: result, + }; +} + +async function parseResponse(response) { + const text = await response.text(); + + if (!text) { + return {}; + } + + try { + return JSON.parse(text); + } catch (error) { + return { message: text }; + } +} + +async function verifyPolicy({ + agentId, + policy, + context = {}, + apiKey = process.env.APORT_API_KEY, + baseUrl = process.env.APORT_BASE_URL || DEFAULT_BASE_URL, + fetchImpl = globalThis.fetch, +}) { + if (!agentId) { + return { + allowed: false, + reason: "Agent ID is required.", + raw: null, + }; + } + + if (!fetchImpl) { + throw new Error("A fetch implementation is required. Use Node.js 18 or newer."); + } + + const headers = { "Content-Type": "application/json" }; + + if (apiKey) { + headers.Authorization = "Bearer " + apiKey; + } + + const response = await fetchImpl( + baseUrl.replace(/\/$/, "") + "/api/verify/policy/" + encodeURIComponent(policy), + { + method: "POST", + headers, + body: JSON.stringify({ + context: { + agent_id: agentId, + policy_id: policy, + context, + }, + }), + } + ); + + const body = await parseResponse(response); + + if (!response.ok) { + return { + allowed: false, + reason: body.message || "APort request failed with status " + response.status, + raw: body, + }; + } + + return normalizeDecision(body); +} + +module.exports = { + formatReasons, + normalizeDecision, + verifyPolicy, +}; diff --git a/examples/developer-tools/discord-bot/src/commands.js b/examples/developer-tools/discord-bot/src/commands.js new file mode 100644 index 0000000..c552c31 --- /dev/null +++ b/examples/developer-tools/discord-bot/src/commands.js @@ -0,0 +1,178 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const { verifyPolicy } = require("./aport"); + +const defaultPolicy = process.env.APORT_DEFAULT_POLICY || "code.repository.merge.v1"; + +const verifyCommand = new SlashCommandBuilder() + .setName("aport-verify") + .setDescription("Verify an agent action with APort") + .addStringOption((option) => + option + .setName("agent-id") + .setDescription("APort agent id or mapped team agent identifier") + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("action") + .setDescription("Team workflow action being requested") + .setRequired(true) + .addChoices( + { name: "Merge pull request", value: "merge_pr" }, + { name: "Process refund", value: "process_refund" }, + { name: "Export data", value: "export_data" }, + { name: "Admin access", value: "admin_access" } + ) + ) + .addStringOption((option) => + option + .setName("policy") + .setDescription("APort policy pack") + .setRequired(false) + ) + .addStringOption((option) => + option + .setName("context") + .setDescription("Optional JSON context, such as {\"amount\":50}") + .setRequired(false) + ); + +const helpCommand = new SlashCommandBuilder() + .setName("aport-help") + .setDescription("Show APort verification bot usage"); + +const commands = [verifyCommand, helpCommand]; + +function parseJsonContext(input) { + if (!input || input.trim() === "") { + return {}; + } + + try { + const parsed = JSON.parse(input); + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Context must be a JSON object."); + } + + return parsed; + } catch (error) { + throw new Error("Invalid context JSON: " + (error instanceof Error ? error.message : String(error))); + } +} + +function buildTeamContext(interaction, action, context) { + return { + action, + discord: { + guildId: interaction.guildId, + channelId: interaction.channelId, + userId: interaction.user.id, + username: interaction.user.username, + }, + ...context, + }; +} + +function createResultEmbed({ allowed, agentId, policy, reason, action }) { + const color = allowed ? 0x1f883d : 0xcf222e; + const title = allowed ? "APort verification approved" : "APort verification denied"; + const description = allowed + ? "Agent `" + agentId + "` is allowed to perform `" + action + "`." + : "Agent `" + agentId + "` is not allowed to perform `" + action + "`."; + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(title) + .setDescription(description) + .addFields( + { name: "Policy", value: "`" + policy + "`", inline: true }, + { name: "Action", value: "`" + action + "`", inline: true } + ) + .setTimestamp(new Date()); + + if (reason) { + embed.addFields({ name: "Reason", value: reason.slice(0, 1024) }); + } + + return embed; +} + +async function handleVerifyCommand(interaction, verifier = verifyPolicy) { + const agentId = interaction.options.getString("agent-id", true); + const action = interaction.options.getString("action", true); + const policy = interaction.options.getString("policy") || defaultPolicy; + const rawContext = interaction.options.getString("context"); + const context = parseJsonContext(rawContext); + const teamContext = buildTeamContext(interaction, action, context); + + await interaction.deferReply({ ephemeral: true }); + + const result = await verifier({ + agentId, + policy, + context: teamContext, + }); + const embed = createResultEmbed({ + allowed: result.allowed, + agentId, + policy, + reason: result.reason, + action, + }); + + await interaction.editReply({ embeds: [embed] }); + + return result; +} + +async function handleHelpCommand(interaction) { + await interaction.reply({ + ephemeral: true, + content: [ + "Use `/aport-verify` to check whether an agent can perform a team workflow action.", + "", + "Example context:", + "```json", + "{\"repository\":\"owner/repo\",\"pull_request\":42}", + "```", + ].join("\n"), + }); +} + +async function handleInteraction(interaction, verifier = verifyPolicy) { + if (!interaction.isChatInputCommand()) { + return; + } + + try { + if (interaction.commandName === "aport-verify") { + await handleVerifyCommand(interaction, verifier); + return; + } + + if (interaction.commandName === "aport-help") { + await handleHelpCommand(interaction); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const payload = { + ephemeral: true, + content: "APort verification failed: " + message, + }; + + if (interaction.deferred || interaction.replied) { + await interaction.editReply(payload); + } else { + await interaction.reply(payload); + } + } +} + +module.exports = { + buildTeamContext, + commands, + createResultEmbed, + handleInteraction, + handleVerifyCommand, + parseJsonContext, +}; diff --git a/examples/developer-tools/discord-bot/src/index.js b/examples/developer-tools/discord-bot/src/index.js new file mode 100644 index 0000000..e279ace --- /dev/null +++ b/examples/developer-tools/discord-bot/src/index.js @@ -0,0 +1,43 @@ +require("dotenv").config(); + +const { Client, GatewayIntentBits, Events } = require("discord.js"); +const { handleInteraction } = require("./commands"); + +function requireEnv(name) { + const value = process.env[name]; + + if (!value) { + throw new Error(name + " is required."); + } + + return value; +} + +async function main() { + const token = requireEnv("DISCORD_TOKEN"); + const client = new Client({ + intents: [GatewayIntentBits.Guilds], + }); + + client.once(Events.ClientReady, (readyClient) => { + console.log("APort Discord bot signed in as " + readyClient.user.tag); + }); + + client.on(Events.InteractionCreate, async (interaction) => { + await handleInteraction(interaction); + }); + + await client.login(token); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { + main, + requireEnv, +}; diff --git a/examples/developer-tools/discord-bot/src/register-commands.js b/examples/developer-tools/discord-bot/src/register-commands.js new file mode 100644 index 0000000..bf97d05 --- /dev/null +++ b/examples/developer-tools/discord-bot/src/register-commands.js @@ -0,0 +1,43 @@ +require("dotenv").config(); + +const { REST, Routes } = require("discord.js"); +const { commands } = require("./commands"); + +function requireEnv(name) { + const value = process.env[name]; + + if (!value) { + throw new Error(name + " is required."); + } + + return value; +} + +async function registerCommands() { + const token = requireEnv("DISCORD_TOKEN"); + const clientId = requireEnv("DISCORD_CLIENT_ID"); + const guildId = process.env.DISCORD_GUILD_ID; + const rest = new REST({ version: "10" }).setToken(token); + const body = commands.map((command) => command.toJSON()); + + if (guildId) { + await rest.put(Routes.applicationGuildCommands(clientId, guildId), { body }); + console.log("Registered APort commands for guild " + guildId); + return; + } + + await rest.put(Routes.applicationCommands(clientId), { body }); + console.log("Registered global APort commands"); +} + +if (require.main === module) { + registerCommands().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { + registerCommands, + requireEnv, +}; diff --git a/examples/developer-tools/discord-bot/tests/aport.test.js b/examples/developer-tools/discord-bot/tests/aport.test.js new file mode 100644 index 0000000..c4525a9 --- /dev/null +++ b/examples/developer-tools/discord-bot/tests/aport.test.js @@ -0,0 +1,76 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + formatReasons, + normalizeDecision, + verifyPolicy, +} = require("../src/aport"); + +test("formats APort decision reasons", () => { + assert.equal( + formatReasons([{ message: "Missing assurance" }, { code: "limit_exceeded" }]), + "Missing assurance, limit_exceeded" + ); +}); + +test("normalizes allow and deny responses", () => { + assert.equal(normalizeDecision({ verified: true }).allowed, true); + + const denied = normalizeDecision({ + data: { + decision: { + allow: false, + reasons: [{ message: "Policy denied" }], + }, + }, + }); + + assert.equal(denied.allowed, false); + assert.equal(denied.reason, "Policy denied"); +}); + +test("verifyPolicy rejects missing agent ids before calling APort", async () => { + const result = await verifyPolicy({ + agentId: "", + policy: "code.repository.merge.v1", + fetchImpl: async () => { + throw new Error("fetch should not be called"); + }, + }); + + assert.equal(result.allowed, false); + assert.equal(result.reason, "Agent ID is required."); +}); + +test("verifyPolicy posts the expected APort request", async () => { + const calls = []; + const result = await verifyPolicy({ + agentId: "ap_agent", + policy: "code.repository.merge.v1", + context: { action: "merge_pr" }, + apiKey: "test-key", + baseUrl: "https://aport.example", + fetchImpl: async (url, options) => { + calls.push({ url, options }); + + return { + ok: true, + async text() { + return JSON.stringify({ decision: { allow: true } }); + }, + }; + }, + }); + + assert.equal(result.allowed, true); + assert.equal(calls[0].url, "https://aport.example/api/verify/policy/code.repository.merge.v1"); + assert.equal(calls[0].options.headers.Authorization, "Bearer test-key"); + assert.deepEqual(JSON.parse(calls[0].options.body), { + context: { + agent_id: "ap_agent", + policy_id: "code.repository.merge.v1", + context: { action: "merge_pr" }, + }, + }); +}); diff --git a/examples/developer-tools/discord-bot/tests/commands.test.js b/examples/developer-tools/discord-bot/tests/commands.test.js new file mode 100644 index 0000000..395e75a --- /dev/null +++ b/examples/developer-tools/discord-bot/tests/commands.test.js @@ -0,0 +1,106 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + buildTeamContext, + createResultEmbed, + handleVerifyCommand, + parseJsonContext, +} = require("../src/commands"); + +function createInteraction(overrides = {}) { + const values = { + "agent-id": "ap_agent", + action: "merge_pr", + policy: "code.repository.merge.v1", + context: "{\"repository\":\"owner/repo\"}", + ...overrides.values, + }; + const interaction = { + guildId: "guild-1", + channelId: "channel-1", + user: { + id: "user-1", + username: "reviewer", + }, + options: { + getString(name, required) { + const value = values[name]; + if (required && !value) { + throw new Error(name + " is required"); + } + return value || null; + }, + }, + deferred: false, + replied: false, + async deferReply(payload) { + this.deferred = true; + this.deferPayload = payload; + }, + async editReply(payload) { + this.editPayload = payload; + }, + }; + + return interaction; +} + +test("parseJsonContext accepts empty and object values", () => { + assert.deepEqual(parseJsonContext(""), {}); + assert.deepEqual(parseJsonContext("{\"amount\":50}"), { amount: 50 }); +}); + +test("parseJsonContext rejects invalid values", () => { + assert.throws(() => parseJsonContext("[]"), /Context must be a JSON object/); + assert.throws(() => parseJsonContext("{"), /Invalid context JSON/); +}); + +test("buildTeamContext includes Discord metadata and custom context", () => { + const context = buildTeamContext(createInteraction(), "merge_pr", { + repository: "owner/repo", + }); + + assert.equal(context.action, "merge_pr"); + assert.equal(context.repository, "owner/repo"); + assert.equal(context.discord.guildId, "guild-1"); + assert.equal(context.discord.username, "reviewer"); +}); + +test("createResultEmbed builds approval and denial embeds", () => { + const approved = createResultEmbed({ + allowed: true, + agentId: "ap_agent", + policy: "code.repository.merge.v1", + action: "merge_pr", + }); + const denied = createResultEmbed({ + allowed: false, + agentId: "ap_agent", + policy: "code.repository.merge.v1", + action: "merge_pr", + reason: "Denied", + }); + + assert.equal(approved.data.title, "APort verification approved"); + assert.equal(denied.data.title, "APort verification denied"); + assert.equal(denied.data.fields.some((field) => field.name === "Reason"), true); +}); + +test("handleVerifyCommand defers and edits the Discord reply", async () => { + const interaction = createInteraction(); + const result = await handleVerifyCommand(interaction, async (request) => { + assert.equal(request.agentId, "ap_agent"); + assert.equal(request.policy, "code.repository.merge.v1"); + assert.equal(request.context.repository, "owner/repo"); + + return { + allowed: true, + raw: {}, + }; + }); + + assert.equal(result.allowed, true); + assert.deepEqual(interaction.deferPayload, { ephemeral: true }); + assert.equal(interaction.editPayload.embeds.length, 1); +});