diff --git a/docs/authentication.mdx b/docs/authentication.mdx new file mode 100644 index 00000000..4b40d9c5 --- /dev/null +++ b/docs/authentication.mdx @@ -0,0 +1,37 @@ +--- +title: Authentication +description: 'How to authenticate with the Volvox Bot API' +--- + +# Authentication + +The API supports two authentication methods: + +## API Key + +Pass your API secret in the `x-api-secret` header: + +```bash +curl -H "x-api-secret: YOUR_SECRET" https://your-domain.com/api/v1/health +``` + +Set via the `BOT_API_SECRET` environment variable. + +## OAuth2 Session (Bearer JWT) + +The web dashboard uses Discord OAuth2: + +1. User clicks "Login with Discord" +2. Discord redirects to `/api/v1/auth/discord/callback` with an authorization code +3. Server exchanges code for tokens and returns a signed JWT +4. Subsequent requests must include the `Authorization: Bearer ` header + +## Public Endpoints + +These endpoints require no authentication: + +- `GET /health` — Basic health check +- `GET /community/{guildId}/leaderboard` — Public leaderboard +- `GET /community/{guildId}/showcases` — Public showcases +- `GET /community/{guildId}/stats` — Public server stats +- `GET /community/{guildId}/profile/{userId}` — Public user profile diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 00000000..b05cabca --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,6 @@ + + Volvox Bot favicon + Placeholder favicon for Mintlify docs. + + + diff --git a/docs/introduction.mdx b/docs/introduction.mdx new file mode 100644 index 00000000..82b25be2 --- /dev/null +++ b/docs/introduction.mdx @@ -0,0 +1,35 @@ +--- +title: Introduction +description: 'Volvox Bot API documentation' +--- + +# Volvox Bot API + +The Volvox Bot REST API powers the web dashboard and provides programmatic access to guild management, moderation, analytics, AI conversations, and community features. + +## Base URL + +``` +https://your-domain.com/api/v1 +``` + +> **Deployment note:** `your-domain.com` is a placeholder. Replace it with your production API domain before publishing these docs. + +## Features + +- **Guild Management** — Server settings, analytics, member management +- **Moderation** — Cases, warnings, bans, timeouts +- **AI Conversations** — Chat history, flagging, search +- **Community** — Leaderboards, showcases, public profiles +- **Tickets** — Support ticket system +- **Config** — Bot configuration CRUD + +## Rate Limiting + +All endpoints return rate limit headers: + +| Header | Description | +|--------|-------------| +| `X-RateLimit-Limit` | Max requests per window | +| `X-RateLimit-Remaining` | Requests remaining | +| `X-RateLimit-Reset` | Unix timestamp when window resets | diff --git a/docs/logo/dark.svg b/docs/logo/dark.svg new file mode 100644 index 00000000..0d4c6750 --- /dev/null +++ b/docs/logo/dark.svg @@ -0,0 +1,7 @@ + + Volvox Bot logo (dark) + Placeholder dark logo for Mintlify docs. + + + + diff --git a/docs/logo/light.svg b/docs/logo/light.svg new file mode 100644 index 00000000..301f2157 --- /dev/null +++ b/docs/logo/light.svg @@ -0,0 +1,7 @@ + + Volvox Bot logo (light) + Placeholder light logo for Mintlify docs. + + + + diff --git a/docs/mint.json b/docs/mint.json new file mode 100644 index 00000000..9a337b82 --- /dev/null +++ b/docs/mint.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://mintlify.com/schema.json", + "name": "Volvox Bot", + "colors": { + "primary": "#22c55e", + "light": "#4ade80", + "dark": "#16a34a", + "anchors": { + "from": "#22c55e", + "to": "#16a34a" + } + }, + "navigation": [ + { + "group": "Getting Started", + "pages": [ + "introduction", + "authentication" + ] + }, + { + "group": "API Reference", + "pages": [ + { + "group": "Health", + "icon": "heart-pulse", + "pages": [ + "api-reference/health/get-health" + ] + }, + { + "group": "Auth", + "icon": "key", + "pages": [ + "api-reference/auth/callback", + "api-reference/auth/session", + "api-reference/auth/logout" + ] + }, + { + "group": "Config", + "icon": "gear", + "pages": [ + "api-reference/config/get-config", + "api-reference/config/update-config" + ] + }, + { + "group": "Guilds", + "icon": "server", + "pages": [ + "api-reference/guilds/list-guilds", + "api-reference/guilds/get-guild", + "api-reference/guilds/list-channels", + "api-reference/guilds/list-roles", + "api-reference/guilds/get-config", + "api-reference/guilds/update-config", + "api-reference/guilds/stats", + "api-reference/guilds/analytics", + "api-reference/guilds/moderation-cases", + "api-reference/guilds/actions" + ] + }, + { + "group": "Members", + "icon": "users", + "pages": [ + "api-reference/members/list-members", + "api-reference/members/export-members", + "api-reference/members/get-member", + "api-reference/members/member-cases", + "api-reference/members/adjust-member-xp" + ] + }, + { + "group": "Moderation", + "icon": "shield", + "pages": [ + "api-reference/moderation/list-cases", + "api-reference/moderation/get-case", + "api-reference/moderation/stats", + "api-reference/moderation/user-history" + ] + }, + { + "group": "Conversations", + "icon": "message", + "pages": [ + "api-reference/conversations/list", + "api-reference/conversations/analytics", + "api-reference/conversations/flagged", + "api-reference/conversations/get-detail", + "api-reference/conversations/flag-message" + ] + }, + { + "group": "Tickets", + "icon": "ticket", + "pages": [ + "api-reference/tickets/stats", + "api-reference/tickets/get-ticket", + "api-reference/tickets/list-tickets" + ] + }, + { + "group": "Community", + "icon": "people-group", + "pages": [ + "api-reference/community/leaderboard", + "api-reference/community/showcases", + "api-reference/community/stats" + ] + }, + { + "group": "Webhooks", + "icon": "webhook", + "pages": [ + "api-reference/webhooks/config-update" + ] + } + ] + } + ], + "openapi": "openapi.json", + "api": { + "baseUrl": "/api/v1" + }, + "footerSocials": { + "github": "https://github.com/VolvoxLLC/volvox-bot", + "discord": "https://discord.gg/volvox" + }, + "logo": { + "dark": "/logo/dark.svg", + "light": "/logo/light.svg" + }, + "favicon": "/favicon.svg" +} diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 00000000..b8713245 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,4167 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Volvox Bot API", + "version": "1.0.0", + "description": "REST API for the Volvox Discord bot — guild management, moderation, analytics, AI conversations, and more." + }, + "servers": [ + { + "url": "/api/v1" + } + ], + "components": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "x-api-secret", + "description": "Shared API secret configured via BOT_API_SECRET environment variable." + }, + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT token provided in the Authorization header as Bearer ." + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message" + } + }, + "required": [ + "error" + ] + }, + "ValidationError": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Individual validation failure messages" + } + }, + "required": [ + "error" + ] + }, + "PaginatedResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "description": "Total number of items" + }, + "page": { + "type": "integer", + "description": "Current page number" + }, + "limit": { + "type": "integer", + "description": "Items per page" + } + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum number of requests allowed in the window", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Remaining": { + "description": "Number of requests remaining in the current window", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp (seconds) when the rate limit window resets", + "schema": { + "type": "number" + } + } + }, + "responses": { + "Unauthorized": { + "description": "Missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Forbidden": { + "description": "Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "ServerError": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ServiceUnavailable": { + "description": "Database or external service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "paths": { + "/auth/discord": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Initiate Discord OAuth2 login", + "description": "Redirects the user to Discord's OAuth2 authorization page. On success, Discord redirects back to the callback URL.", + "responses": { + "302": { + "description": "Redirect to Discord authorization page" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "description": "OAuth2 not configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/discord/callback": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Discord OAuth2 callback", + "description": "Handles the OAuth2 callback from Discord. Exchanges the authorization code for an access token, fetches user info, creates a JWT session, sets an httpOnly cookie, and redirects to the dashboard.\n", + "parameters": [ + { + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + }, + "description": "Authorization code from Discord" + }, + { + "in": "query", + "name": "state", + "required": true, + "schema": { + "type": "string" + }, + "description": "CSRF state parameter" + } + ], + "responses": { + "302": { + "description": "Redirect to dashboard with session cookie set" + }, + "400": { + "description": "Missing authorization code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Failed to exchange code or fetch user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Invalid or expired OAuth state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "502": { + "description": "Invalid response from Discord", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/me": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Get current user", + "description": "Returns the authenticated user's profile and guild list. Requires a valid Bearer JWT in the Authorization header.", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Current user info", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "avatar": { + "type": "string", + "nullable": true + }, + "guilds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "permissions": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/auth/logout": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Log out", + "description": "Invalidates the server-side session. Requires a valid Bearer JWT in the Authorization header.", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successfully logged out", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Logged out successfully" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/community/{guildId}/leaderboard": { + "get": { + "tags": [ + "Community" + ], + "summary": "XP leaderboard", + "description": "Returns top members ranked by XP. Only members with public profiles are included. No auth required.", + "parameters": [ + { + "in": "path", + "name": "guildId", + "required": true, + "schema": { + "type": "string" + }, + "description": "Discord guild ID" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "minimum": 1, + "maximum": 100 + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Leaderboard page", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string", + "nullable": true + }, + "xp": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "badge": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "currentLevelXp": { + "type": "integer" + }, + "nextLevelXp": { + "type": "integer" + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/community/{guildId}/showcases": { + "get": { + "tags": [ + "Community" + ], + "summary": "Project showcase gallery", + "description": "Returns community project showcases sorted by upvotes or recency. No auth required.", + "parameters": [ + { + "in": "path", + "name": "guildId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 12, + "minimum": 1, + "maximum": 50 + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "upvotes", + "recent" + ], + "default": "upvotes" + } + } + ], + "responses": { + "200": { + "description": "Showcase gallery page", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tech": { + "type": "array", + "items": { + "type": "string" + } + }, + "repoUrl": { + "type": "string", + "nullable": true + }, + "liveUrl": { + "type": "string", + "nullable": true + }, + "authorName": { + "type": "string" + }, + "authorAvatar": { + "type": "string", + "nullable": true + }, + "upvotes": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/community/{guildId}/stats": { + "get": { + "tags": [ + "Community" + ], + "summary": "Community statistics", + "description": "Returns aggregate community statistics including member count, messages, projects, and top contributors. No auth required.", + "parameters": [ + { + "in": "path", + "name": "guildId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Community stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "memberCount": { + "type": "integer" + }, + "totalMessagesSent": { + "type": "integer" + }, + "activeProjects": { + "type": "integer" + }, + "challengesCompleted": { + "type": "integer" + }, + "topContributors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string", + "nullable": true + }, + "xp": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "badge": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/community/{guildId}/profile/{userId}": { + "get": { + "tags": [ + "Community" + ], + "summary": "Public user profile", + "description": "Returns a user's public profile including stats, XP, badges, and project showcases. Returns 404 if the user has not opted in to a public profile.", + "parameters": [ + { + "in": "path", + "name": "guildId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string", + "nullable": true + }, + "xp": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "currentLevelXp": { + "type": "integer" + }, + "nextLevelXp": { + "type": "integer" + }, + "badge": { + "type": "string" + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "stats": { + "type": "object", + "properties": { + "messagesSent": { + "type": "integer" + }, + "reactionsGiven": { + "type": "integer" + }, + "reactionsReceived": { + "type": "integer" + }, + "daysActive": { + "type": "integer" + } + } + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tech": { + "type": "array", + "items": { + "type": "string" + } + }, + "repoUrl": { + "type": "string", + "nullable": true + }, + "liveUrl": { + "type": "string", + "nullable": true + }, + "upvotes": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + }, + "recentBadges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/config": { + "get": { + "tags": [ + "Config" + ], + "summary": "Get global config", + "description": "Returns the current global bot configuration. Restricted to API-secret callers or bot-owner OAuth users. Sensitive fields are masked.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Current global config (readable sections, sensitive fields masked)", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Config object with section keys (ai, welcome, spam, moderation, etc.)" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "tags": [ + "Config" + ], + "summary": "Update global config", + "description": "Replace writable config sections. Only writable sections (ai, welcome, spam, moderation, triage) are accepted. Values are merged leaf-by-leaf into the existing config. Restricted to API-secret callers or bot-owner OAuth users.\n", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Config sections to update", + "example": { + "ai": { + "model": "claude-3" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated config (all writes succeeded)", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "207": { + "description": "Partial success — some writes failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed" + ] + }, + "error": { + "type": "string" + } + } + } + }, + "config": { + "type": "object" + } + } + } + } + } + }, + "400": { + "description": "Invalid request body or validation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/guilds/{id}/conversations": { + "get": { + "tags": [ + "Conversations" + ], + "summary": "List AI conversations", + "description": "Returns AI conversations grouped by channel and time proximity. Messages within 15 minutes in the same channel are grouped together. Defaults to the last 30 days.\n", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + } + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + }, + "description": "Full-text search in message content" + }, + { + "in": "query", + "name": "user", + "schema": { + "type": "string" + }, + "description": "Filter by username" + }, + { + "in": "query", + "name": "channel", + "schema": { + "type": "string" + }, + "description": "Filter by channel ID" + }, + { + "in": "query", + "name": "from", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "Start date filter" + }, + { + "in": "query", + "name": "to", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "End date filter" + } + ], + "responses": { + "200": { + "description": "Paginated conversation list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "conversations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "channelId": { + "type": "string" + }, + "channelName": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "role": { + "type": "string" + } + } + } + }, + "messageCount": { + "type": "integer" + }, + "firstMessageAt": { + "type": "string", + "format": "date-time" + }, + "lastMessageAt": { + "type": "string", + "format": "date-time" + }, + "preview": { + "type": "string" + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/conversations/stats": { + "get": { + "tags": [ + "Conversations" + ], + "summary": "Conversation analytics", + "description": "Returns aggregate statistics about AI conversations for the guild.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + } + ], + "responses": { + "200": { + "description": "Conversation analytics", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "totalConversations": { + "type": "integer" + }, + "totalMessages": { + "type": "integer" + }, + "avgMessagesPerConversation": { + "type": "integer" + }, + "topUsers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "messageCount": { + "type": "integer" + } + } + } + }, + "dailyActivity": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "count": { + "type": "integer" + } + } + } + }, + "estimatedTokens": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/conversations/flags": { + "get": { + "tags": [ + "Conversations" + ], + "summary": "List flagged messages", + "description": "Returns flagged AI messages with optional status filter.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "open", + "resolved", + "dismissed" + ] + } + } + ], + "responses": { + "200": { + "description": "Flagged messages", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "flags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "guildId": { + "type": "string" + }, + "conversationFirstId": { + "type": "integer" + }, + "messageId": { + "type": "integer" + }, + "flaggedBy": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "open", + "resolved", + "dismissed" + ] + }, + "resolvedBy": { + "type": "string", + "nullable": true + }, + "resolvedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "messageContent": { + "type": "string", + "nullable": true + }, + "messageRole": { + "type": "string", + "nullable": true + }, + "messageUsername": { + "type": "string", + "nullable": true + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/conversations/{conversationId}": { + "get": { + "tags": [ + "Conversations" + ], + "summary": "Get conversation detail", + "description": "Returns all messages in a conversation for replay, including flag status and token estimates.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "path", + "name": "conversationId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the first message in the conversation" + } + ], + "responses": { + "200": { + "description": "Conversation detail with messages", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "content": { + "type": "string" + }, + "username": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "flagStatus": { + "type": "string", + "nullable": true, + "enum": [ + "open", + "resolved", + "dismissed" + ] + } + } + } + }, + "channelId": { + "type": "string" + }, + "duration": { + "type": "integer", + "description": "Duration in seconds" + }, + "tokenEstimate": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Invalid conversation ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/conversations/{conversationId}/flag": { + "post": { + "tags": [ + "Conversations" + ], + "summary": "Flag a message", + "description": "Flag a problematic AI response in a conversation for review.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "path", + "name": "conversationId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Conversation ID (first message ID)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "messageId", + "reason" + ], + "properties": { + "messageId": { + "type": "integer", + "description": "ID of the message to flag" + }, + "reason": { + "type": "string", + "maxLength": 500 + }, + "notes": { + "type": "string", + "maxLength": 2000 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Message flagged successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "flagId": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "open" + ] + } + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds": { + "get": { + "tags": [ + "Guilds" + ], + "summary": "List guilds", + "description": "For OAuth users: returns guilds where the user has MANAGE_GUILD or ADMINISTRATOR. Bot owners see all guilds. For API-secret users: returns all bot guilds.\n", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Guild list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string", + "nullable": true + }, + "memberCount": { + "type": "integer" + }, + "access": { + "type": "string", + "enum": [ + "admin", + "moderator", + "bot-owner" + ] + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "502": { + "description": "Failed to fetch guilds from Discord", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}": { + "get": { + "tags": [ + "Guilds" + ], + "summary": "Get guild info", + "description": "Returns detailed information about a specific guild.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + } + ], + "responses": { + "200": { + "description": "Guild details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string", + "nullable": true + }, + "memberCount": { + "type": "integer" + }, + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "integer", + "description": "Discord channel type enum (0=Text, 2=Voice, 4=Category, 5=Announcement, 13=Stage, 15=Forum, 16=Media)" + } + } + } + }, + "channelCount": { + "type": "integer", + "description": "Total number of channels in the guild" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/guilds/{id}/channels": { + "get": { + "tags": [ + "Guilds" + ], + "summary": "List guild channels", + "description": "Returns all channels in the guild (capped at 500).", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Channel list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "integer" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/guilds/{id}/roles": { + "get": { + "tags": [ + "Guilds" + ], + "summary": "List guild roles", + "description": "Returns all roles in the guild (capped at 250).", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Role list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "integer", + "description": "Role color as decimal integer (for example 16711680)" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/guilds/{id}/config": { + "get": { + "tags": [ + "Guilds" + ], + "summary": "Get guild config", + "description": "Returns per-guild configuration (global defaults merged with guild overrides). Sensitive fields are masked.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Guild config", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "patch": { + "tags": [ + "Guilds" + ], + "summary": "Update guild config", + "description": "Updates per-guild configuration overrides. Only writable sections are accepted.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated guild config section", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid config", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/guilds/{id}/stats": { + "get": { + "tags": [ + "Guilds" + ], + "summary": "Guild statistics", + "description": "Returns aggregate guild statistics — member count, AI conversations, moderation cases, and uptime.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Guild stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "guildId": { + "type": "string" + }, + "memberCount": { + "type": "integer" + }, + "aiConversations": { + "type": "integer", + "description": "Total AI conversations logged for this guild" + }, + "moderationCases": { + "type": "integer", + "description": "Total moderation cases for this guild" + }, + "uptime": { + "type": "number", + "description": "Bot process uptime in seconds" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/analytics": { + "get": { + "tags": [ + "Guilds" + ], + "summary": "Guild analytics", + "description": "Returns time-series analytics data for dashboard charts — messages, joins/leaves, active members, AI usage, XP distribution, and more.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "range", + "schema": { + "type": "string", + "enum": [ + "today", + "week", + "month", + "custom" + ], + "default": "week" + }, + "description": "Preset time range. Use 'custom' with from/to for a specific window." + }, + { + "in": "query", + "name": "from", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "Start of custom date range (ISO 8601). Required when range=custom." + }, + { + "in": "query", + "name": "to", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "End of custom date range (ISO 8601). Required when range=custom." + }, + { + "in": "query", + "name": "interval", + "schema": { + "type": "string", + "enum": [ + "hour", + "day" + ] + }, + "description": "Bucket size for time-series data. Auto-selected if omitted." + }, + { + "in": "query", + "name": "compare", + "schema": { + "type": "string", + "enum": [ + "1", + "true", + "yes", + "on" + ] + }, + "description": "When set, includes comparison data for the previous equivalent period." + }, + { + "in": "query", + "name": "channelId", + "schema": { + "type": "string" + }, + "description": "Optional filter by channel ID" + } + ], + "responses": { + "200": { + "description": "Analytics dataset", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid analytics query parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/moderation": { + "get": { + "tags": [ + "Guilds" + ], + "summary": "Recent moderation cases", + "description": "Returns recent moderation cases for the guild overview. Requires moderator permissions.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Moderation cases", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cases": { + "type": "array", + "items": { + "type": "object" + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/actions": { + "post": { + "tags": [ + "Guilds" + ], + "summary": "Trigger guild action", + "description": "Trigger a bot action on a guild. Supported actions: sendMessage (post a text message to a channel). Restricted to API-secret authentication only.\n", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "The action to perform" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Message sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "channelId": { + "type": "string" + }, + "content": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Unknown action", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/health": { + "get": { + "tags": [ + "Health" + ], + "summary": "Health check", + "description": "Returns server status and uptime. When a valid `x-api-secret` header is provided, includes extended diagnostics (Discord connection, memory, system info, error counts, restart history).\n", + "parameters": [ + { + "in": "header", + "name": "x-api-secret", + "schema": { + "type": "string" + }, + "required": false, + "description": "Optional — include for extended diagnostics" + } + ], + "responses": { + "200": { + "description": "Server health status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "uptime": { + "type": "number", + "description": "Server uptime in seconds" + }, + "discord": { + "type": "object", + "description": "Discord connection info (auth only)", + "properties": { + "status": { + "type": "integer" + }, + "ping": { + "type": "integer" + }, + "guilds": { + "type": "integer" + } + } + }, + "memory": { + "type": "object", + "description": "Process memory usage (auth only)" + }, + "system": { + "type": "object", + "description": "System info (auth only)", + "properties": { + "platform": { + "type": "string" + }, + "nodeVersion": { + "type": "string" + } + } + }, + "errors": { + "type": "object", + "description": "Error counts (auth only)", + "properties": { + "lastHour": { + "type": "integer", + "nullable": true + }, + "lastDay": { + "type": "integer", + "nullable": true + } + } + }, + "restarts": { + "type": "array", + "description": "Recent restart history (auth only)", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string" + }, + "version": { + "type": "string", + "nullable": true + }, + "uptimeBefore": { + "type": "number", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + }, + "/guilds/{id}/members/export": { + "get": { + "tags": [ + "Members" + ], + "summary": "Export members as CSV", + "description": "Streams a CSV file with enriched member data (stats, XP, warnings). May take a while for large guilds.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + } + ], + "responses": { + "200": { + "description": "CSV file download", + "content": { + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/members": { + "get": { + "tags": [ + "Members" + ], + "summary": "List members", + "description": "Returns enriched member list with stats, XP, and warning counts. Supports search, sort, and cursor-based pagination.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "minimum": 1, + "maximum": 100 + } + }, + { + "in": "query", + "name": "after", + "schema": { + "type": "string" + }, + "description": "Cursor for Discord pagination (member ID)" + }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + }, + "description": "Search by username or display name" + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "messages", + "xp", + "warnings", + "joined" + ], + "default": "joined" + } + }, + { + "in": "query", + "name": "order", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "Enriched member list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, + "joinedAt": { + "type": "string", + "format": "date-time" + }, + "messages_sent": { + "type": "integer" + }, + "days_active": { + "type": "integer" + }, + "last_active": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "xp": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "warning_count": { + "type": "integer" + } + } + } + }, + "nextAfter": { + "type": "string", + "nullable": true, + "description": "Cursor for next page" + }, + "total": { + "type": "integer", + "description": "Total guild member count" + }, + "filteredTotal": { + "type": "integer", + "description": "Only present when search is active" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/members/{userId}": { + "get": { + "tags": [ + "Members" + ], + "summary": "Get member detail", + "description": "Returns full member profile including stats, XP, level progression, roles, and recent warnings.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + }, + "description": "Discord user ID" + } + ], + "responses": { + "200": { + "description": "Member detail", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + } + } + }, + "joinedAt": { + "type": "string", + "format": "date-time" + }, + "stats": { + "type": "object", + "nullable": true, + "properties": { + "messages_sent": { + "type": "integer" + }, + "reactions_given": { + "type": "integer" + }, + "reactions_received": { + "type": "integer" + }, + "days_active": { + "type": "integer" + }, + "first_seen": { + "type": "string", + "format": "date-time" + }, + "last_active": { + "type": "string", + "format": "date-time" + } + } + }, + "reputation": { + "type": "object", + "properties": { + "xp": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "messages_count": { + "type": "integer" + }, + "voice_minutes": { + "type": "integer" + }, + "helps_given": { + "type": "integer" + }, + "last_xp_gain": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "next_level_xp": { + "type": "integer", + "nullable": true + } + } + }, + "warnings": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "recent": { + "type": "array", + "items": { + "type": "object", + "properties": { + "case_number": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "moderator_tag": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/members/{userId}/cases": { + "get": { + "tags": [ + "Members" + ], + "summary": "Member mod case history", + "description": "Returns paginated moderation case history for a specific member.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Member case history", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "cases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "case_number": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "moderator_id": { + "type": "string" + }, + "moderator_tag": { + "type": "string" + }, + "duration": { + "type": "string", + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "pages": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/members/{userId}/xp": { + "post": { + "tags": [ + "Members" + ], + "summary": "Adjust member XP", + "description": "Add or remove XP for a member. XP floors at 0. Amount must be a non-zero integer between -1,000,000 and 1,000,000.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "integer", + "description": "XP adjustment (positive or negative, max ±1,000,000)" + }, + "reason": { + "type": "string", + "description": "Optional reason for the adjustment" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated XP/level", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "xp": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "adjustment": { + "type": "integer" + }, + "reason": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "400": { + "description": "Invalid amount", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/moderation/cases": { + "get": { + "tags": [ + "Moderation" + ], + "summary": "List mod cases", + "description": "Returns paginated moderation cases for a guild with optional filters by target user or action type.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "guildId", + "required": true, + "schema": { + "type": "string" + }, + "description": "Discord guild ID" + }, + { + "in": "query", + "name": "targetId", + "schema": { + "type": "string" + }, + "description": "Filter by target user ID" + }, + { + "in": "query", + "name": "action", + "schema": { + "type": "string", + "enum": [ + "warn", + "kick", + "ban", + "mute", + "unmute", + "unban" + ] + }, + "description": "Filter by action type" + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Paginated mod cases", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "case_number": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "target_tag": { + "type": "string" + }, + "moderator_id": { + "type": "string" + }, + "moderator_tag": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "duration": { + "type": "string", + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "log_message_id": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "pages": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Missing guildId", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/moderation/cases/{caseNumber}": { + "get": { + "tags": [ + "Moderation" + ], + "summary": "Get mod case detail", + "description": "Returns a single moderation case by case number, including any scheduled actions.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "caseNumber", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "guildId", + "required": true, + "schema": { + "type": "string" + }, + "description": "Discord guild ID (scopes the lookup)" + } + ], + "responses": { + "200": { + "description": "Mod case with scheduled actions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "guild_id": { + "type": "string" + }, + "case_number": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "target_tag": { + "type": "string" + }, + "moderator_id": { + "type": "string" + }, + "moderator_tag": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "duration": { + "type": "string", + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "scheduledActions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "execute_at": { + "type": "string", + "format": "date-time" + }, + "executed": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid case number or missing guildId", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/moderation/stats": { + "get": { + "tags": [ + "Moderation" + ], + "summary": "Moderation statistics", + "description": "Returns aggregate moderation statistics for a guild — totals, recent activity, breakdown by action, and top targets.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "guildId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Moderation stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "totalCases": { + "type": "integer" + }, + "last24h": { + "type": "integer" + }, + "last7d": { + "type": "integer" + }, + "byAction": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "topTargets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Missing guildId", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/moderation/user/{userId}/history": { + "get": { + "tags": [ + "Moderation" + ], + "summary": "User moderation history", + "description": "Returns full moderation history for a specific user in a guild with breakdown by action type.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + }, + "description": "Discord user ID" + }, + { + "in": "query", + "name": "guildId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "User moderation history", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "cases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "case_number": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "moderator_tag": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "pages": { + "type": "integer" + }, + "byAction": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "Missing guildId or userId", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/guilds/{id}/tickets/stats": { + "get": { + "tags": [ + "Tickets" + ], + "summary": "Ticket statistics", + "description": "Returns ticket statistics — open count, average resolution time, and tickets created this week.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + } + ], + "responses": { + "200": { + "description": "Ticket stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "openCount": { + "type": "integer" + }, + "avgResolutionSeconds": { + "type": "integer" + }, + "ticketsThisWeek": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/tickets/{ticketId}": { + "get": { + "tags": [ + "Tickets" + ], + "summary": "Get ticket detail", + "description": "Returns a single ticket with full details and transcript.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "path", + "name": "ticketId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Ticket ID" + } + ], + "responses": { + "200": { + "description": "Ticket detail", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "guild_id": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "topic": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "thread_id": { + "type": "string", + "nullable": true + }, + "channel_id": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "close_reason": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + }, + "400": { + "description": "Invalid ticket ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/guilds/{id}/tickets": { + "get": { + "tags": [ + "Tickets" + ], + "summary": "List tickets", + "description": "Returns paginated tickets with optional status and user filters.", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Guild ID" + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "open", + "closed" + ] + } + }, + { + "in": "query", + "name": "user", + "schema": { + "type": "string" + }, + "description": "Filter by user ID" + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Paginated ticket list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tickets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "guild_id": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "topic": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "thread_id": { + "type": "string", + "nullable": true + }, + "channel_id": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "close_reason": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/ServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/webhooks/config-update": { + "post": { + "tags": [ + "Webhooks" + ], + "summary": "Push config update", + "description": "Receives a config update pushed from the dashboard. Persists the change to the specified guild's config. Requires API secret authentication only (OAuth is not accepted).\n", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "guildId", + "path", + "value" + ], + "properties": { + "guildId": { + "type": "string", + "description": "Target guild ID" + }, + "path": { + "type": "string", + "description": "Dot-notated config path (e.g. \"ai.model\")" + }, + "value": { + "description": "New value to set" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated config section", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid request body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Requires API secret authentication (OAuth not accepted)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + } + }, + "tags": [] +} \ No newline at end of file diff --git a/package.json b/package.json index 61d2158a..d9c5c4d0 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,12 @@ "migrate": "node-pg-migrate up --migrations-dir migrations", "migrate:down": "node-pg-migrate down --migrations-dir migrations", "migrate:create": "node-pg-migrate create --migrations-dir migrations", - "prepare": "[ -d .git ] && git config core.hooksPath .hooks || true" + "prepare": "[ -d .git ] && git config core.hooksPath .hooks || true", + "docs:generate": "node scripts/generate-openapi.js" }, "dependencies": { "@anthropic-ai/claude-code": "^2.1.44", + "@anthropic-ai/sdk": "^0.78.0", "@sentry/node": "^10.40.0", "discord.js": "^14.25.1", "dotenv": "^17.3.1", @@ -31,11 +33,11 @@ "mem0ai": "^2.2.3", "node-pg-migrate": "^8.0.4", "pg": "^8.18.0", + "swagger-jsdoc": "^6.2.8", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "winston-transport": "^4.9.0", - "ws": "^8.19.0", - "@anthropic-ai/sdk": "^0.78.0" + "ws": "^8.19.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a5d9941..4aa04f6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: pg: specifier: ^8.18.0 version: 8.18.0 + swagger-jsdoc: + specifier: ^6.2.8 + version: 6.2.8(openapi-types@12.1.3) winston: specifier: ^3.19.0 version: 3.19.0 @@ -197,6 +200,21 @@ packages: zod: optional: true + '@apidevtools/json-schema-ref-parser@9.1.2': + resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@10.0.3': + resolution: {integrity: sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==} + peerDependencies: + openapi-types: '>=7' + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -863,6 +881,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@langchain/core@0.3.80': resolution: {integrity: sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==} engines: {node: '>=18'} @@ -2420,6 +2441,9 @@ packages: '@types/jest@29.5.14': resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mysql@2.15.27': resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} @@ -2602,6 +2626,9 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This package is no longer supported. + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -2710,6 +2737,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -2806,6 +2836,14 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@6.2.0: + resolution: {integrity: sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==} + engines: {node: '>= 6'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -2995,6 +3033,10 @@ packages: resolution: {integrity: sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==} engines: {node: '>=18'} + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3096,6 +3138,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -3284,6 +3330,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@7.1.6: + resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -3519,6 +3569,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -3652,6 +3706,10 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -3661,6 +3719,10 @@ packages: lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} @@ -3673,6 +3735,9 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -4044,6 +4109,9 @@ packages: zod: optional: true + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openid-client@5.7.1: resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} @@ -4598,6 +4666,15 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + swagger-jsdoc@6.2.8: + resolution: {integrity: sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==} + engines: {node: '>=12.0.0'} + hasBin: true + + swagger-parser@10.0.3: + resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==} + engines: {node: '>=10'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4766,6 +4843,10 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + validator@13.15.26: + resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -4964,6 +5045,10 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@2.0.0-1: + resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==} + engines: {node: '>= 6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4972,6 +5057,11 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + z-schema@5.0.5: + resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} + engines: {node: '>=8.0.0'} + hasBin: true + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -5007,6 +5097,27 @@ snapshots: optionalDependencies: zod: 3.25.76 + '@apidevtools/json-schema-ref-parser@9.1.2': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + call-me-maybe: 1.0.2 + js-yaml: 4.1.1 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@10.0.3(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 9.1.2 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + '@jsdevtools/ono': 7.1.3 + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + z-schema: 5.0.5 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -5606,7 +5717,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.11 + '@types/node': 25.3.2 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -5629,6 +5740,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} + '@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76))': dependencies: '@cfworker/json-schema': 4.1.1 @@ -7230,6 +7343,8 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/json-schema@7.0.15': {} + '@types/mysql@2.15.27': dependencies: '@types/node': 22.19.11 @@ -7254,7 +7369,6 @@ snapshots: '@types/node@25.3.2': dependencies: undici-types: 7.18.2 - optional: true '@types/pg-pool@2.0.7': dependencies: @@ -7280,7 +7394,7 @@ snapshots: '@types/sqlite3@3.1.11': dependencies: - '@types/node': 22.19.11 + '@types/node': 25.3.2 '@types/stack-utils@2.0.3': {} @@ -7459,6 +7573,8 @@ snapshots: readable-stream: 3.6.2 optional: true + argparse@2.0.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -7531,7 +7647,6 @@ snapshots: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - optional: true brace-expansion@2.0.2: dependencies: @@ -7605,6 +7720,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + call-me-maybe@1.0.2: {} + camelcase@6.3.0: {} caniuse-lite@1.0.30001770: {} @@ -7690,11 +7807,15 @@ snapshots: dependencies: delayed-stream: 1.0.0 - component-emitter@1.3.1: {} + commander@6.2.0: {} - concat-map@0.0.1: + commander@9.5.0: optional: true + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + console-control-strings@1.1.0: optional: true @@ -7851,6 +7972,10 @@ snapshots: - bufferutil - utf-8-validate + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -7961,6 +8086,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -8101,8 +8228,7 @@ snapshots: minipass: 3.3.6 optional: true - fs.realpath@1.0.0: - optional: true + fs.realpath@1.0.0: {} fsevents@2.3.3: optional: true @@ -8184,6 +8310,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@7.1.6: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -8327,7 +8462,6 @@ snapshots: dependencies: once: 1.4.0 wrappy: 1.0.2 - optional: true inherits@2.0.4: {} @@ -8437,7 +8571,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.19.11 + '@types/node': 25.3.2 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -8455,6 +8589,10 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsdom@26.1.0: dependencies: cssstyle: 4.6.0 @@ -8585,12 +8723,16 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.get@4.4.2: {} + lodash.includes@4.3.0: {} lodash.isarguments@3.1.0: {} lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} lodash.isnumber@3.0.3: {} @@ -8599,6 +8741,8 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.mergewith@4.6.2: {} + lodash.once@4.1.1: {} lodash.snakecase@4.1.1: {} @@ -8747,7 +8891,6 @@ snapshots: minimatch@3.1.5: dependencies: brace-expansion: 1.1.12 - optional: true minimatch@9.0.9: dependencies: @@ -8989,6 +9132,8 @@ snapshots: transitivePeerDependencies: - encoding + openapi-types@12.1.3: {} + openid-client@5.7.1: dependencies: jose: 4.15.9 @@ -9029,8 +9174,7 @@ snapshots: parseurl@1.3.3: {} - path-is-absolute@1.0.1: - optional: true + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -9168,7 +9312,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.19.11 + '@types/node': 25.3.2 long: 5.3.2 proxy-addr@2.0.7: @@ -9697,6 +9841,23 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-jsdoc@6.2.8(openapi-types@12.1.3): + dependencies: + commander: 6.2.0 + doctrine: 3.0.0 + glob: 7.1.6 + lodash.mergewith: 4.6.2 + swagger-parser: 10.0.3(openapi-types@12.1.3) + yaml: 2.0.0-1 + transitivePeerDependencies: + - openapi-types + + swagger-parser@10.0.3(openapi-types@12.1.3): + dependencies: + '@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.3) + transitivePeerDependencies: + - openapi-types + symbol-tree@3.2.4: {} tailwind-merge@3.4.1: {} @@ -9791,8 +9952,7 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.18.2: - optional: true + undici-types@7.18.2: {} undici@7.22.0: {} @@ -9841,6 +10001,8 @@ snapshots: uuid@9.0.1: {} + validator@13.15.26: {} + vary@1.1.2: {} victory-vendor@37.3.6: @@ -10072,6 +10234,8 @@ snapshots: yallist@5.0.0: {} + yaml@2.0.0-1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -10084,6 +10248,14 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + z-schema@5.0.5: + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 13.15.26 + optionalDependencies: + commander: 9.5.0 + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/scripts/generate-openapi.js b/scripts/generate-openapi.js new file mode 100644 index 00000000..da91f0ff --- /dev/null +++ b/scripts/generate-openapi.js @@ -0,0 +1,7 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { info } from '../src/logger.js'; +import { swaggerSpec } from '../src/api/swagger.js'; + +mkdirSync('docs', { recursive: true }); +writeFileSync('docs/openapi.json', JSON.stringify(swaggerSpec, null, 2)); +info('OpenAPI spec written to docs/openapi.json'); diff --git a/src/api/middleware/rateLimit.js b/src/api/middleware/rateLimit.js index 99db3321..51d1f6dd 100644 --- a/src/api/middleware/rateLimit.js +++ b/src/api/middleware/rateLimit.js @@ -41,6 +41,11 @@ export function rateLimit({ windowMs = 15 * 60 * 1000, max = 100 } = {}) { entry.count++; + // Emit rate-limit headers on every response so clients can track their quota + res.set('X-RateLimit-Limit', String(max)); + res.set('X-RateLimit-Remaining', String(Math.max(0, max - entry.count))); + res.set('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))); + if (entry.count > max) { const retryAfter = Math.ceil((entry.resetAt - now) / 1000); res.set('Retry-After', String(retryAfter)); diff --git a/src/api/routes/auth.js b/src/api/routes/auth.js index aa0f7568..d371f561 100644 --- a/src/api/routes/auth.js +++ b/src/api/routes/auth.js @@ -109,7 +109,24 @@ export function stopAuthCleanup() { const oauthRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 }); /** - * GET /discord — Redirect to Discord OAuth2 authorization + * @openapi + * /auth/discord: + * get: + * tags: + * - Auth + * summary: Initiate Discord OAuth2 login + * description: Redirects the user to Discord's OAuth2 authorization page. On success, Discord redirects back to the callback URL. + * responses: + * "302": + * description: Redirect to Discord authorization page + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * description: OAuth2 not configured + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" */ router.get('/discord', oauthRateLimit, (_req, res) => { const clientId = process.env.DISCORD_CLIENT_ID; @@ -147,8 +164,58 @@ router.get('/discord', oauthRateLimit, (_req, res) => { }); /** - * GET /discord/callback — Handle Discord OAuth2 callback - * Exchanges code for token, fetches user info, creates JWT + * @openapi + * /auth/discord/callback: + * get: + * tags: + * - Auth + * summary: Discord OAuth2 callback + * description: > + * Handles the OAuth2 callback from Discord. Exchanges the authorization code + * for an access token, fetches user info, creates a JWT session, sets an httpOnly + * cookie, and redirects to the dashboard. + * parameters: + * - in: query + * name: code + * required: true + * schema: + * type: string + * description: Authorization code from Discord + * - in: query + * name: state + * required: true + * schema: + * type: string + * description: CSRF state parameter + * responses: + * "302": + * description: Redirect to dashboard with session cookie set + * "400": + * description: Missing authorization code + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * description: Failed to exchange code or fetch user + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "403": + * description: Invalid or expired OAuth state + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "500": + * $ref: "#/components/responses/ServerError" + * "502": + * description: Invalid response from Discord + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" */ router.get('/discord/callback', async (req, res) => { cleanExpiredStates(); @@ -283,8 +350,45 @@ router.get('/discord/callback', async (req, res) => { }); /** - * GET /me — Return current authenticated user info from JWT - * Fetches fresh guilds from Discord using the stored access token + * @openapi + * /auth/me: + * get: + * tags: + * - Auth + * summary: Get current user + * description: Returns the authenticated user's profile and guild list. Requires a valid Bearer JWT in the Authorization header. + * security: + * - BearerAuth: [] + * responses: + * "200": + * description: Current user info + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * username: + * type: string + * avatar: + * type: string + * nullable: true + * guilds: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * permissions: + * type: string + * "401": + * $ref: "#/components/responses/Unauthorized" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/me', requireOAuth(), async (req, res) => { const { userId, username, avatar } = req.user; @@ -316,7 +420,28 @@ router.get('/me', requireOAuth(), async (req, res) => { }); /** - * POST /logout — Invalidate the user's server-side session + * @openapi + * /auth/logout: + * post: + * tags: + * - Auth + * summary: Log out + * description: Invalidates the server-side session. Requires a valid Bearer JWT in the Authorization header. + * security: + * - BearerAuth: [] + * responses: + * "200": + * description: Successfully logged out + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Logged out successfully + * "401": + * $ref: "#/components/responses/Unauthorized" */ router.post('/logout', requireOAuth(), async (req, res) => { try { diff --git a/src/api/routes/community.js b/src/api/routes/community.js index 4391cd4a..6b90f84f 100644 --- a/src/api/routes/community.js +++ b/src/api/routes/community.js @@ -55,8 +55,77 @@ function getDbPool(req) { // ─── GET /:guildId/leaderboard ──────────────────────────────────────────────── /** - * GET /:guildId/leaderboard — Top members by XP (public profiles only) - * Query: ?limit=25&page=1 + * @openapi + * /community/{guildId}/leaderboard: + * get: + * tags: + * - Community + * summary: XP leaderboard + * description: Returns top members ranked by XP. Only members with public profiles are included. No auth required. + * parameters: + * - in: path + * name: guildId + * required: true + * schema: + * type: string + * description: Discord guild ID + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * minimum: 1 + * maximum: 100 + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * minimum: 1 + * responses: + * "200": + * description: Leaderboard page + * content: + * application/json: + * schema: + * type: object + * properties: + * members: + * type: array + * items: + * type: object + * properties: + * userId: + * type: string + * username: + * type: string + * displayName: + * type: string + * avatar: + * type: string + * nullable: true + * xp: + * type: integer + * level: + * type: integer + * badge: + * type: string + * rank: + * type: integer + * currentLevelXp: + * type: integer + * nextLevelXp: + * type: integer + * total: + * type: integer + * page: + * type: integer + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:guildId/leaderboard', async (req, res) => { const { guildId } = req.params; @@ -146,8 +215,86 @@ router.get('/:guildId/leaderboard', async (req, res) => { // ─── GET /:guildId/showcases ────────────────────────────────────────────────── /** - * GET /:guildId/showcases — Project showcase gallery - * Query: ?limit=12&page=1&sort=upvotes|recent + * @openapi + * /community/{guildId}/showcases: + * get: + * tags: + * - Community + * summary: Project showcase gallery + * description: Returns community project showcases sorted by upvotes or recency. No auth required. + * parameters: + * - in: path + * name: guildId + * required: true + * schema: + * type: string + * - in: query + * name: limit + * schema: + * type: integer + * default: 12 + * minimum: 1 + * maximum: 50 + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: sort + * schema: + * type: string + * enum: [upvotes, recent] + * default: upvotes + * responses: + * "200": + * description: Showcase gallery page + * content: + * application/json: + * schema: + * type: object + * properties: + * projects: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * title: + * type: string + * description: + * type: string + * tech: + * type: array + * items: + * type: string + * repoUrl: + * type: string + * nullable: true + * liveUrl: + * type: string + * nullable: true + * authorName: + * type: string + * authorAvatar: + * type: string + * nullable: true + * upvotes: + * type: integer + * createdAt: + * type: string + * format: date-time + * total: + * type: integer + * page: + * type: integer + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:guildId/showcases', async (req, res) => { const { guildId } = req.params; @@ -231,7 +378,61 @@ router.get('/:guildId/showcases', async (req, res) => { // ─── GET /:guildId/stats ────────────────────────────────────────────────────── /** - * GET /:guildId/stats — Community stats banner + * @openapi + * /community/{guildId}/stats: + * get: + * tags: + * - Community + * summary: Community statistics + * description: Returns aggregate community statistics including member count, messages, projects, and top contributors. No auth required. + * parameters: + * - in: path + * name: guildId + * required: true + * schema: + * type: string + * responses: + * "200": + * description: Community stats + * content: + * application/json: + * schema: + * type: object + * properties: + * memberCount: + * type: integer + * totalMessagesSent: + * type: integer + * activeProjects: + * type: integer + * challengesCompleted: + * type: integer + * topContributors: + * type: array + * items: + * type: object + * properties: + * userId: + * type: string + * username: + * type: string + * displayName: + * type: string + * avatar: + * type: string + * nullable: true + * xp: + * type: integer + * level: + * type: integer + * badge: + * type: string + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:guildId/stats', async (req, res) => { const { guildId } = req.params; @@ -327,8 +528,107 @@ router.get('/:guildId/stats', async (req, res) => { // ─── GET /:guildId/profile/:userId ──────────────────────────────────────────── /** - * GET /:guildId/profile/:userId — Public user profile - * Only returns data if user has public_profile = true. + * @openapi + * /community/{guildId}/profile/{userId}: + * get: + * tags: + * - Community + * summary: Public user profile + * description: Returns a user's public profile including stats, XP, badges, and project showcases. Returns 404 if the user has not opted in to a public profile. + * parameters: + * - in: path + * name: guildId + * required: true + * schema: + * type: string + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * "200": + * description: User profile + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * displayName: + * type: string + * avatar: + * type: string + * nullable: true + * xp: + * type: integer + * level: + * type: integer + * currentLevelXp: + * type: integer + * nextLevelXp: + * type: integer + * badge: + * type: string + * joinedAt: + * type: string + * format: date-time + * nullable: true + * stats: + * type: object + * properties: + * messagesSent: + * type: integer + * reactionsGiven: + * type: integer + * reactionsReceived: + * type: integer + * daysActive: + * type: integer + * projects: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * title: + * type: string + * description: + * type: string + * tech: + * type: array + * items: + * type: string + * repoUrl: + * type: string + * nullable: true + * liveUrl: + * type: string + * nullable: true + * upvotes: + * type: integer + * createdAt: + * type: string + * format: date-time + * recentBadges: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * "404": + * $ref: "#/components/responses/NotFound" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:guildId/profile/:userId', async (req, res) => { const { guildId, userId } = req.params; diff --git a/src/api/routes/config.js b/src/api/routes/config.js index 1b291258..15021f9d 100644 --- a/src/api/routes/config.js +++ b/src/api/routes/config.js @@ -101,7 +101,28 @@ function requireGlobalAdmin(req, res, next) { } /** - * GET / — Retrieve current global config (readable sections only) + * @openapi + * /config: + * get: + * tags: + * - Config + * summary: Get global config + * description: Returns the current global bot configuration. Restricted to API-secret callers or bot-owner OAuth users. Sensitive fields are masked. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * responses: + * "200": + * description: Current global config (readable sections, sensitive fields masked) + * content: + * application/json: + * schema: + * type: object + * description: Config object with section keys (ai, welcome, spam, moderation, etc.) + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" */ router.get('/', requireGlobalAdmin, (_req, res) => { const config = getConfig(); @@ -117,10 +138,71 @@ router.get('/', requireGlobalAdmin, (_req, res) => { }); /** - * PUT / — Update global config with schema validation - * Body: { "ai": { ... }, "welcome": { ... } } - * Only writable sections (ai, welcome, spam, moderation, triage) are accepted. - * Values are merged into existing config via setConfigValue. + * @openapi + * /config: + * put: + * tags: + * - Config + * summary: Update global config + * description: > + * Replace writable config sections. Only writable sections (ai, welcome, spam, + * moderation, triage) are accepted. Values are merged leaf-by-leaf into the + * existing config. Restricted to API-secret callers or bot-owner OAuth users. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: Config sections to update + * example: + * ai: + * model: claude-3 + * responses: + * "200": + * description: Updated config (all writes succeeded) + * content: + * application/json: + * schema: + * type: object + * "207": + * description: Partial success — some writes failed + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * results: + * type: array + * items: + * type: object + * properties: + * path: + * type: string + * status: + * type: string + * enum: [success, failed] + * error: + * type: string + * config: + * type: object + * "400": + * description: Invalid request body or validation failure + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/ValidationError" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "500": + * $ref: "#/components/responses/ServerError" */ router.put('/', requireGlobalAdmin, async (req, res) => { if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) { diff --git a/src/api/routes/conversations.js b/src/api/routes/conversations.js index 55e2e7a6..fa87ac2e 100644 --- a/src/api/routes/conversations.js +++ b/src/api/routes/conversations.js @@ -144,8 +144,116 @@ function buildConversationSummary(convo, guild) { // ─── GET / — List conversations (grouped) ───────────────────────────────────── /** - * GET / — List conversations grouped by channel + time proximity - * Query params: ?page=1&limit=25&search=&user=&channel=&from=&to= + * @openapi + * /guilds/{id}/conversations: + * get: + * tags: + * - Conversations + * summary: List AI conversations + * description: > + * Returns AI conversations grouped by channel and time proximity. + * Messages within 15 minutes in the same channel are grouped together. + * Defaults to the last 30 days. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * - in: query + * name: search + * schema: + * type: string + * description: Full-text search in message content + * - in: query + * name: user + * schema: + * type: string + * description: Filter by username + * - in: query + * name: channel + * schema: + * type: string + * description: Filter by channel ID + * - in: query + * name: from + * schema: + * type: string + * format: date-time + * description: Start date filter + * - in: query + * name: to + * schema: + * type: string + * format: date-time + * description: End date filter + * responses: + * "200": + * description: Paginated conversation list + * content: + * application/json: + * schema: + * type: object + * properties: + * conversations: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * channelId: + * type: string + * channelName: + * type: string + * participants: + * type: array + * items: + * type: object + * properties: + * username: + * type: string + * role: + * type: string + * messageCount: + * type: integer + * firstMessageAt: + * type: string + * format: date-time + * lastMessageAt: + * type: string + * format: date-time + * preview: + * type: string + * total: + * type: integer + * page: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/', conversationsRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { const { dbPool } = req.app.locals; @@ -237,8 +345,68 @@ router.get('/', conversationsRateLimit, requireGuildAdmin, validateGuild, async // ─── GET /stats — Conversation analytics ────────────────────────────────────── /** - * GET /stats — Conversation analytics - * Returns aggregate stats about conversations for the guild. + * @openapi + * /guilds/{id}/conversations/stats: + * get: + * tags: + * - Conversations + * summary: Conversation analytics + * description: Returns aggregate statistics about AI conversations for the guild. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * responses: + * "200": + * description: Conversation analytics + * content: + * application/json: + * schema: + * type: object + * properties: + * totalConversations: + * type: integer + * totalMessages: + * type: integer + * avgMessagesPerConversation: + * type: integer + * topUsers: + * type: array + * items: + * type: object + * properties: + * username: + * type: string + * messageCount: + * type: integer + * dailyActivity: + * type: array + * items: + * type: object + * properties: + * date: + * type: string + * format: date + * count: + * type: integer + * estimatedTokens: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/stats', conversationsRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { const { dbPool } = req.app.locals; @@ -327,8 +495,103 @@ router.get('/stats', conversationsRateLimit, requireGuildAdmin, validateGuild, a // ─── GET /flags — List flagged messages ─────────────────────────────────────── /** - * GET /flags — List flagged messages - * Query params: ?page=1&limit=25&status=open|resolved|dismissed + * @openapi + * /guilds/{id}/conversations/flags: + * get: + * tags: + * - Conversations + * summary: List flagged messages + * description: Returns flagged AI messages with optional status filter. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * - in: query + * name: status + * schema: + * type: string + * enum: [open, resolved, dismissed] + * responses: + * "200": + * description: Flagged messages + * content: + * application/json: + * schema: + * type: object + * properties: + * flags: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * guildId: + * type: string + * conversationFirstId: + * type: integer + * messageId: + * type: integer + * flaggedBy: + * type: string + * reason: + * type: string + * notes: + * type: string + * nullable: true + * status: + * type: string + * enum: [open, resolved, dismissed] + * resolvedBy: + * type: string + * nullable: true + * resolvedAt: + * type: string + * format: date-time + * nullable: true + * createdAt: + * type: string + * format: date-time + * messageContent: + * type: string + * nullable: true + * messageRole: + * type: string + * nullable: true + * messageUsername: + * type: string + * nullable: true + * total: + * type: integer + * page: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/flags', conversationsRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { const { dbPool } = req.app.locals; @@ -402,8 +665,82 @@ router.get('/flags', conversationsRateLimit, requireGuildAdmin, validateGuild, a // ─── GET /:conversationId — Single conversation detail ──────────────────────── /** - * GET /:conversationId — Fetch all messages in a conversation for replay - * The conversationId is the ID of the first message in the conversation. + * @openapi + * /guilds/{id}/conversations/{conversationId}: + * get: + * tags: + * - Conversations + * summary: Get conversation detail + * description: Returns all messages in a conversation for replay, including flag status and token estimates. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: path + * name: conversationId + * required: true + * schema: + * type: integer + * description: ID of the first message in the conversation + * responses: + * "200": + * description: Conversation detail with messages + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * role: + * type: string + * content: + * type: string + * username: + * type: string + * createdAt: + * type: string + * format: date-time + * flagStatus: + * type: string + * nullable: true + * enum: [open, resolved, dismissed] + * channelId: + * type: string + * duration: + * type: integer + * description: Duration in seconds + * tokenEstimate: + * type: integer + * "400": + * description: Invalid conversation ID + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get( '/:conversationId', @@ -512,8 +849,79 @@ router.get( // ─── POST /:conversationId/flag — Flag a message ───────────────────────────── /** - * POST /:conversationId/flag — Flag a problematic AI response - * Body: { messageId: number, reason: string, notes?: string } + * @openapi + * /guilds/{id}/conversations/{conversationId}/flag: + * post: + * tags: + * - Conversations + * summary: Flag a message + * description: Flag a problematic AI response in a conversation for review. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: path + * name: conversationId + * required: true + * schema: + * type: integer + * description: Conversation ID (first message ID) + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - messageId + * - reason + * properties: + * messageId: + * type: integer + * description: ID of the message to flag + * reason: + * type: string + * maxLength: 500 + * notes: + * type: string + * maxLength: 2000 + * responses: + * "201": + * description: Message flagged successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * flagId: + * type: integer + * status: + * type: string + * enum: [open] + * "400": + * description: Invalid input + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.post( '/:conversationId/flag', diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index d5450e80..0e3e23e9 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -315,12 +315,52 @@ export function validateGuild(req, res, next) { } /** - * GET / — List guilds - * For OAuth2 users: - * - bot owners: return all guilds where the bot is present (access = "bot-owner") - * - non-owners: fetch fresh guilds from Discord and return only guilds where user has - * ADMINISTRATOR (access = "admin") or MANAGE_GUILD (access = "moderator"), and bot is present - * For api-secret users: returns all bot guilds + * @openapi + * /guilds: + * get: + * tags: + * - Guilds + * summary: List guilds + * description: > + * For OAuth users: returns guilds where the user has MANAGE_GUILD or ADMINISTRATOR. + * Bot owners see all guilds. For API-secret users: returns all bot guilds. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * responses: + * "200": + * description: Guild list + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * icon: + * type: string + * nullable: true + * memberCount: + * type: integer + * access: + * type: string + * enum: [admin, moderator, bot-owner] + * "401": + * $ref: "#/components/responses/Unauthorized" + * "502": + * description: Failed to fetch guilds from Discord + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" + * "500": + * $ref: "#/components/responses/ServerError" */ router.get('/', async (req, res) => { const { client } = req.app.locals; @@ -423,7 +463,61 @@ function getGuildChannels(guild) { } /** - * GET /:id — Guild info + * @openapi + * /guilds/{id}: + * get: + * tags: + * - Guilds + * summary: Get guild info + * description: Returns detailed information about a specific guild. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * responses: + * "200": + * description: Guild details + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * icon: + * type: string + * nullable: true + * memberCount: + * type: integer + * channels: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * type: + * type: integer + * description: "Discord channel type enum (0=Text, 2=Voice, 4=Category, 5=Announcement, 13=Stage, 15=Forum, 16=Media)" + * channelCount: + * type: integer + * description: Total number of channels in the guild + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" */ router.get('/:id', requireGuildAdmin, validateGuild, (req, res) => { const guild = req.guild; @@ -438,14 +532,89 @@ router.get('/:id', requireGuildAdmin, validateGuild, (req, res) => { }); /** - * GET /:id/channels — Guild channel list + * @openapi + * /guilds/{id}/channels: + * get: + * tags: + * - Guilds + * summary: List guild channels + * description: Returns all channels in the guild (capped at 500). + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * "200": + * description: Channel list + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * type: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" */ router.get('/:id/channels', requireGuildAdmin, validateGuild, (req, res) => { res.json(getGuildChannels(req.guild)); }); /** - * GET /:id/roles — Guild role list + * @openapi + * /guilds/{id}/roles: + * get: + * tags: + * - Guilds + * summary: List guild roles + * description: Returns all roles in the guild (capped at 250). + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * "200": + * description: Role list + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * color: + * type: integer + * description: Role color as decimal integer (for example 16711680) + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" */ router.get('/:id/roles', requireGuildAdmin, validateGuild, (req, res) => { const guild = req.guild; @@ -458,8 +627,35 @@ router.get('/:id/roles', requireGuildAdmin, validateGuild, (req, res) => { }); /** - * GET /:id/config — Read guild config (safe keys only) - * Returns per-guild config (global defaults merged with guild overrides). + * @openapi + * /guilds/{id}/config: + * get: + * tags: + * - Guilds + * summary: Get guild config + * description: Returns per-guild configuration (global defaults merged with guild overrides). Sensitive fields are masked. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * "200": + * description: Guild config + * content: + * application/json: + * schema: + * type: object + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" */ router.get('/:id/config', requireGuildAdmin, validateGuild, (req, res) => { const config = getConfig(req.params.id); @@ -476,9 +672,49 @@ router.get('/:id/config', requireGuildAdmin, validateGuild, (req, res) => { }); /** - * PATCH /:id/config — Update a guild-specific config value (safe keys only) - * Body: { path: "ai.model", value: "claude-3" } - * Writes to the per-guild config overrides for the requested guild. + * @openapi + * /guilds/{id}/config: + * patch: + * tags: + * - Guilds + * summary: Update guild config + * description: Updates per-guild configuration overrides. Only writable sections are accepted. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * responses: + * "200": + * description: Updated guild config section + * content: + * application/json: + * schema: + * type: object + * "400": + * description: Invalid config + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/ValidationError" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "500": + * $ref: "#/components/responses/ServerError" */ router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) => { if (!req.body) { @@ -516,7 +752,53 @@ router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) = }); /** - * GET /:id/stats — Guild statistics + * @openapi + * /guilds/{id}/stats: + * get: + * tags: + * - Guilds + * summary: Guild statistics + * description: Returns aggregate guild statistics — member count, AI conversations, moderation cases, and uptime. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * "200": + * description: Guild stats + * content: + * application/json: + * schema: + * type: object + * properties: + * guildId: + * type: string + * memberCount: + * type: integer + * aiConversations: + * type: integer + * description: Total AI conversations logged for this guild + * moderationCases: + * type: integer + * description: Total moderation cases for this guild + * uptime: + * type: number + * description: Bot process uptime in seconds + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:id/stats', requireGuildAdmin, validateGuild, async (req, res) => { const { dbPool } = req.app.locals; @@ -554,13 +836,81 @@ router.get('/:id/stats', requireGuildAdmin, validateGuild, async (req, res) => { }); /** - * GET /:id/analytics — Dashboard analytics dataset - * Query params: - * - range=today|week|month|custom - * - from= (required for custom) - * - to= (required for custom) - * - interval=hour|day (optional; auto-derived when omitted) - * - channelId= (optional filter) + * @openapi + * /guilds/{id}/analytics: + * get: + * tags: + * - Guilds + * summary: Guild analytics + * description: Returns time-series analytics data for dashboard charts — messages, joins/leaves, active members, AI usage, XP distribution, and more. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: query + * name: range + * schema: + * type: string + * enum: [today, week, month, custom] + * default: week + * description: Preset time range. Use 'custom' with from/to for a specific window. + * - in: query + * name: from + * schema: + * type: string + * format: date-time + * description: Start of custom date range (ISO 8601). Required when range=custom. + * - in: query + * name: to + * schema: + * type: string + * format: date-time + * description: End of custom date range (ISO 8601). Required when range=custom. + * - in: query + * name: interval + * schema: + * type: string + * enum: [hour, day] + * description: Bucket size for time-series data. Auto-selected if omitted. + * - in: query + * name: compare + * schema: + * type: string + * enum: ["1", "true", "yes", "on"] + * description: When set, includes comparison data for the previous equivalent period. + * - in: query + * name: channelId + * schema: + * type: string + * description: Optional filter by channel ID + * responses: + * "200": + * description: Analytics dataset + * content: + * application/json: + * schema: + * type: object + * "400": + * description: Invalid analytics query parameters + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:id/analytics', requireGuildAdmin, validateGuild, async (req, res) => { const { dbPool } = req.app.locals; @@ -1002,8 +1352,61 @@ router.get('/:id/analytics', requireGuildAdmin, validateGuild, async (req, res) }); /** - * GET /:id/moderation — Paginated moderation cases - * Query params: ?page=1&limit=25 (max 100) + * @openapi + * /guilds/{id}/moderation: + * get: + * tags: + * - Guilds + * summary: Recent moderation cases + * description: Returns recent moderation cases for the guild overview. Requires moderator permissions. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * responses: + * "200": + * description: Moderation cases + * content: + * application/json: + * schema: + * type: object + * properties: + * cases: + * type: array + * items: + * type: object + * total: + * type: integer + * page: + * type: integer + * limit: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:id/moderation', requireGuildModerator, validateGuild, async (req, res) => { const { dbPool } = req.app.locals; @@ -1044,9 +1447,63 @@ router.get('/:id/moderation', requireGuildModerator, validateGuild, async (req, }); /** - * POST /:id/actions — Execute bot actions - * Body: { action: "sendMessage", channelId: "...", content: "..." } - * Restricted to API-secret callers to prevent CSRF via browser-based OAuth sessions. + * @openapi + * /guilds/{id}/actions: + * post: + * tags: + * - Guilds + * summary: Trigger guild action + * description: > + * Trigger a bot action on a guild. Supported actions: sendMessage (post a text message + * to a channel). Restricted to API-secret authentication only. + * security: + * - ApiKeyAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - action + * properties: + * action: + * type: string + * description: The action to perform + * responses: + * "201": + * description: Message sent + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * channelId: + * type: string + * content: + * type: string + * "400": + * description: Unknown action + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "500": + * $ref: "#/components/responses/ServerError" */ router.post('/:id/actions', requireGuildAdmin, validateGuild, async (req, res) => { if (req.authMethod !== 'api-secret') { diff --git a/src/api/routes/health.js b/src/api/routes/health.js index 678d7638..5fa17807 100644 --- a/src/api/routes/health.js +++ b/src/api/routes/health.js @@ -49,9 +49,85 @@ try { } /** - * GET / — Health check endpoint - * Returns status, uptime, and Discord connection details. - * Includes extended data only when a valid x-api-secret header is provided. + * @openapi + * /health: + * get: + * tags: + * - Health + * summary: Health check + * description: > + * Returns server status and uptime. When a valid `x-api-secret` header is + * provided, includes extended diagnostics (Discord connection, memory, + * system info, error counts, restart history). + * parameters: + * - in: header + * name: x-api-secret + * schema: + * type: string + * required: false + * description: Optional — include for extended diagnostics + * responses: + * "200": + * description: Server health status + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + * uptime: + * type: number + * description: Server uptime in seconds + * discord: + * type: object + * description: Discord connection info (auth only) + * properties: + * status: + * type: integer + * ping: + * type: integer + * guilds: + * type: integer + * memory: + * type: object + * description: Process memory usage (auth only) + * system: + * type: object + * description: System info (auth only) + * properties: + * platform: + * type: string + * nodeVersion: + * type: string + * errors: + * type: object + * description: Error counts (auth only) + * properties: + * lastHour: + * type: integer + * nullable: true + * lastDay: + * type: integer + * nullable: true + * restarts: + * type: array + * description: Recent restart history (auth only) + * items: + * type: object + * properties: + * timestamp: + * type: string + * format: date-time + * reason: + * type: string + * version: + * type: string + * nullable: true + * uptimeBefore: + * type: number + * nullable: true */ router.get('/', async (req, res) => { const { client } = req.app.locals; diff --git a/src/api/routes/members.js b/src/api/routes/members.js index c57b53b8..05d4a773 100644 --- a/src/api/routes/members.js +++ b/src/api/routes/members.js @@ -46,8 +46,40 @@ function safeGetPool() { // ─── GET /:id/members/export — CSV export (must be before /:userId) ────────── /** - * GET /:id/members/export — Export all members with stats as CSV - * Streams a CSV file with enriched member data. + * @openapi + * /guilds/{id}/members/export: + * get: + * tags: + * - Members + * summary: Export members as CSV + * description: Streams a CSV file with enriched member data (stats, XP, warnings). May take a while for large guilds. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * responses: + * "200": + * description: CSV file download + * content: + * text/csv: + * schema: + * type: string + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get( '/:id/members/export', @@ -148,13 +180,119 @@ router.get( // ─── GET /:id/members — Enhanced member list ───────────────────────────────── /** - * GET /:id/members — Enhanced member list with bot data - * Query params: - * limit (default 25, max 100) - * after — cursor for Discord pagination - * search — filter by username/displayName - * sort — messages|xp|warnings|joined (default: joined) - * order — asc|desc (default: desc) + * @openapi + * /guilds/{id}/members: + * get: + * tags: + * - Members + * summary: List members + * description: Returns enriched member list with stats, XP, and warning counts. Supports search, sort, and cursor-based pagination. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * minimum: 1 + * maximum: 100 + * - in: query + * name: after + * schema: + * type: string + * description: Cursor for Discord pagination (member ID) + * - in: query + * name: search + * schema: + * type: string + * description: Search by username or display name + * - in: query + * name: sort + * schema: + * type: string + * enum: [messages, xp, warnings, joined] + * default: joined + * - in: query + * name: order + * schema: + * type: string + * enum: [asc, desc] + * default: desc + * responses: + * "200": + * description: Enriched member list + * content: + * application/json: + * schema: + * type: object + * properties: + * members: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * username: + * type: string + * displayName: + * type: string + * avatar: + * type: string + * roles: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * joinedAt: + * type: string + * format: date-time + * messages_sent: + * type: integer + * days_active: + * type: integer + * last_active: + * type: string + * format: date-time + * nullable: true + * xp: + * type: integer + * level: + * type: integer + * warning_count: + * type: integer + * nextAfter: + * type: string + * nullable: true + * description: Cursor for next page + * total: + * type: integer + * description: Total guild member count + * filteredTotal: + * type: integer + * description: Only present when search is active + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:id/members', membersRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { let limit = Number.parseInt(req.query.limit, 10) || 25; @@ -295,7 +433,130 @@ router.get('/:id/members', membersRateLimit, requireGuildAdmin, validateGuild, a // ─── GET /:id/members/:userId — Member detail ──────────────────────────────── /** - * GET /:id/members/:userId — Full member profile with stats, XP, and warnings + * @openapi + * /guilds/{id}/members/{userId}: + * get: + * tags: + * - Members + * summary: Get member detail + * description: Returns full member profile including stats, XP, level progression, roles, and recent warnings. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: path + * name: userId + * required: true + * schema: + * type: string + * description: Discord user ID + * responses: + * "200": + * description: Member detail + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * username: + * type: string + * displayName: + * type: string + * avatar: + * type: string + * roles: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * color: + * type: string + * joinedAt: + * type: string + * format: date-time + * stats: + * type: object + * nullable: true + * properties: + * messages_sent: + * type: integer + * reactions_given: + * type: integer + * reactions_received: + * type: integer + * days_active: + * type: integer + * first_seen: + * type: string + * format: date-time + * last_active: + * type: string + * format: date-time + * reputation: + * type: object + * properties: + * xp: + * type: integer + * level: + * type: integer + * messages_count: + * type: integer + * voice_minutes: + * type: integer + * helps_given: + * type: integer + * last_xp_gain: + * type: string + * format: date-time + * nullable: true + * next_level_xp: + * type: integer + * nullable: true + * warnings: + * type: object + * properties: + * count: + * type: integer + * recent: + * type: array + * items: + * type: object + * properties: + * case_number: + * type: integer + * action: + * type: string + * reason: + * type: string + * moderator_tag: + * type: string + * created_at: + * type: string + * format: date-time + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get( '/:id/members/:userId', @@ -409,10 +670,91 @@ router.get( // ─── GET /:id/members/:userId/cases — Full moderation history ───────────────── /** - * GET /:id/members/:userId/cases — Paginated mod case history for a user - * Query params: - * page (default 1) - * limit (default 25, max 100) + * @openapi + * /guilds/{id}/members/{userId}/cases: + * get: + * tags: + * - Members + * summary: Member mod case history + * description: Returns paginated moderation case history for a specific member. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: path + * name: userId + * required: true + * schema: + * type: string + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * responses: + * "200": + * description: Member case history + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * cases: + * type: array + * items: + * type: object + * properties: + * case_number: + * type: integer + * action: + * type: string + * reason: + * type: string + * nullable: true + * moderator_id: + * type: string + * moderator_tag: + * type: string + * duration: + * type: string + * nullable: true + * expires_at: + * type: string + * format: date-time + * nullable: true + * created_at: + * type: string + * format: date-time + * total: + * type: integer + * page: + * type: integer + * pages: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get( '/:id/members/:userId/cases', @@ -472,9 +814,78 @@ router.get( // ─── POST /:id/members/:userId/xp — Admin XP adjustment ────────────────────── /** - * POST /:id/members/:userId/xp — Adjust a member's XP - * Body: { amount: number, reason?: string } - * Positive or negative adjustment. Returns updated XP/level. + * @openapi + * /guilds/{id}/members/{userId}/xp: + * post: + * tags: + * - Members + * summary: Adjust member XP + * description: Add or remove XP for a member. XP floors at 0. Amount must be a non-zero integer between -1,000,000 and 1,000,000. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: path + * name: userId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - amount + * properties: + * amount: + * type: integer + * description: XP adjustment (positive or negative, max ±1,000,000) + * reason: + * type: string + * description: Optional reason for the adjustment + * responses: + * "200": + * description: Updated XP/level + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * xp: + * type: integer + * level: + * type: integer + * adjustment: + * type: integer + * reason: + * type: string + * nullable: true + * "400": + * description: Invalid amount + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.post( '/:id/members/:userId/xp', diff --git a/src/api/routes/moderation.js b/src/api/routes/moderation.js index ab825f34..c9c35f47 100644 --- a/src/api/routes/moderation.js +++ b/src/api/routes/moderation.js @@ -36,14 +36,110 @@ router.use(adaptGuildIdParam, requireGuildModerator); // ─── GET /cases ─────────────────────────────────────────────────────────────── /** - * List mod cases for a guild with optional filters and pagination. - * - * Query params: - * guildId (required) — Discord guild ID - * targetId — Filter by target user ID - * action — Filter by action type (warn, kick, ban, …) - * page (default 1) - * limit (default 25, max 100) + * @openapi + * /moderation/cases: + * get: + * tags: + * - Moderation + * summary: List mod cases + * description: Returns paginated moderation cases for a guild with optional filters by target user or action type. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: query + * name: guildId + * required: true + * schema: + * type: string + * description: Discord guild ID + * - in: query + * name: targetId + * schema: + * type: string + * description: Filter by target user ID + * - in: query + * name: action + * schema: + * type: string + * enum: [warn, kick, ban, mute, unmute, unban] + * description: Filter by action type + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * responses: + * "200": + * description: Paginated mod cases + * content: + * application/json: + * schema: + * type: object + * properties: + * cases: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * case_number: + * type: integer + * action: + * type: string + * target_id: + * type: string + * target_tag: + * type: string + * moderator_id: + * type: string + * moderator_tag: + * type: string + * reason: + * type: string + * nullable: true + * duration: + * type: string + * nullable: true + * expires_at: + * type: string + * format: date-time + * nullable: true + * log_message_id: + * type: string + * nullable: true + * created_at: + * type: string + * format: date-time + * total: + * type: integer + * page: + * type: integer + * limit: + * type: integer + * pages: + * type: integer + * "400": + * description: Missing guildId + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" */ router.get('/cases', moderationRateLimit, async (req, res) => { const { guildId, targetId, action } = req.query; @@ -109,6 +205,7 @@ router.get('/cases', moderationRateLimit, async (req, res) => { cases: casesResult.rows, total, page, + limit, pages, }); } catch (err) { @@ -120,10 +217,100 @@ router.get('/cases', moderationRateLimit, async (req, res) => { // ─── GET /cases/:caseNumber ──────────────────────────────────────────────────── /** - * Get a single mod case by case_number + guild, including any scheduled actions. - * - * Query params: - * guildId (required) — scoped to prevent cross-guild data exposure + * @openapi + * /moderation/cases/{caseNumber}: + * get: + * tags: + * - Moderation + * summary: Get mod case detail + * description: Returns a single moderation case by case number, including any scheduled actions. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: caseNumber + * required: true + * schema: + * type: integer + * - in: query + * name: guildId + * required: true + * schema: + * type: string + * description: Discord guild ID (scopes the lookup) + * responses: + * "200": + * description: Mod case with scheduled actions + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * guild_id: + * type: string + * case_number: + * type: integer + * action: + * type: string + * target_id: + * type: string + * target_tag: + * type: string + * moderator_id: + * type: string + * moderator_tag: + * type: string + * reason: + * type: string + * nullable: true + * duration: + * type: string + * nullable: true + * expires_at: + * type: string + * format: date-time + * nullable: true + * created_at: + * type: string + * format: date-time + * scheduledActions: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * action: + * type: string + * target_id: + * type: string + * execute_at: + * type: string + * format: date-time + * executed: + * type: boolean + * created_at: + * type: string + * format: date-time + * "400": + * description: Invalid case number or missing guildId + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" */ router.get('/cases/:caseNumber', moderationRateLimit, async (req, res) => { const caseNumber = parseInt(req.params.caseNumber, 10); @@ -186,10 +373,65 @@ router.get('/cases/:caseNumber', moderationRateLimit, async (req, res) => { // ─── GET /stats ─────────────────────────────────────────────────────────────── /** - * Get moderation stats summary for a guild. - * - * Query params: - * guildId (required) + * @openapi + * /moderation/stats: + * get: + * tags: + * - Moderation + * summary: Moderation statistics + * description: Returns aggregate moderation statistics for a guild — totals, recent activity, breakdown by action, and top targets. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: query + * name: guildId + * required: true + * schema: + * type: string + * responses: + * "200": + * description: Moderation stats + * content: + * application/json: + * schema: + * type: object + * properties: + * totalCases: + * type: integer + * last24h: + * type: integer + * last7d: + * type: integer + * byAction: + * type: object + * additionalProperties: + * type: integer + * topTargets: + * type: array + * items: + * type: object + * properties: + * userId: + * type: string + * tag: + * type: string + * count: + * type: integer + * "400": + * description: Missing guildId + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" */ router.get('/stats', moderationRateLimit, async (req, res) => { const { guildId } = req.query; @@ -265,12 +507,94 @@ router.get('/stats', moderationRateLimit, async (req, res) => { // ─── GET /user/:userId/history ──────────────────────────────────────────────── /** - * Get full moderation history for a specific user in a guild. - * - * Query params: - * guildId (required) — Discord guild ID - * page (default 1) - * limit (default 25, max 100) + * @openapi + * /moderation/user/{userId}/history: + * get: + * tags: + * - Moderation + * summary: User moderation history + * description: Returns full moderation history for a specific user in a guild with breakdown by action type. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * description: Discord user ID + * - in: query + * name: guildId + * required: true + * schema: + * type: string + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * responses: + * "200": + * description: User moderation history + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * cases: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * case_number: + * type: integer + * action: + * type: string + * reason: + * type: string + * nullable: true + * moderator_tag: + * type: string + * created_at: + * type: string + * format: date-time + * total: + * type: integer + * page: + * type: integer + * limit: + * type: integer + * pages: + * type: integer + * byAction: + * type: object + * additionalProperties: + * type: integer + * "400": + * description: Missing guildId or userId + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" */ router.get('/user/:userId/history', moderationRateLimit, async (req, res) => { const { userId } = req.params; @@ -341,6 +665,7 @@ router.get('/user/:userId/history', moderationRateLimit, async (req, res) => { cases: casesResult.rows, total, page, + limit, pages, byAction, }); diff --git a/src/api/routes/tickets.js b/src/api/routes/tickets.js index f6942597..a25a2f71 100644 --- a/src/api/routes/tickets.js +++ b/src/api/routes/tickets.js @@ -28,8 +28,47 @@ function getDbPool(req) { // ─── GET /:id/tickets/stats ─────────────────────────────────────────────────── /** - * GET /:id/tickets/stats — Ticket statistics for a guild. - * Returns open count, avg resolution time, and tickets this week. + * @openapi + * /guilds/{id}/tickets/stats: + * get: + * tags: + * - Tickets + * summary: Ticket statistics + * description: Returns ticket statistics — open count, average resolution time, and tickets created this week. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * responses: + * "200": + * description: Ticket stats + * content: + * application/json: + * schema: + * type: object + * properties: + * openCount: + * type: integer + * avgResolutionSeconds: + * type: integer + * ticketsThisWeek: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get( '/:id/tickets/stats', @@ -78,7 +117,86 @@ router.get( // ─── GET /:id/tickets/:ticketId ─────────────────────────────────────────────── /** - * GET /:id/tickets/:ticketId — Ticket detail with transcript. + * @openapi + * /guilds/{id}/tickets/{ticketId}: + * get: + * tags: + * - Tickets + * summary: Get ticket detail + * description: Returns a single ticket with full details and transcript. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: path + * name: ticketId + * required: true + * schema: + * type: integer + * description: Ticket ID + * responses: + * "200": + * description: Ticket detail + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * guild_id: + * type: string + * user_id: + * type: string + * topic: + * type: string + * nullable: true + * status: + * type: string + * enum: [open, closed] + * thread_id: + * type: string + * nullable: true + * channel_id: + * type: string + * nullable: true + * closed_by: + * type: string + * nullable: true + * close_reason: + * type: string + * nullable: true + * created_at: + * type: string + * format: date-time + * closed_at: + * type: string + * format: date-time + * nullable: true + * "400": + * description: Invalid ticket ID + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "404": + * $ref: "#/components/responses/NotFound" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get( '/:id/tickets/:ticketId', @@ -116,13 +234,104 @@ router.get( // ─── GET /:id/tickets ───────────────────────────────────────────────────────── /** - * GET /:id/tickets — List tickets with pagination and filters. - * - * Query params: - * status — Filter by status (open, closed) - * user — Filter by user ID - * page — Page number (default 1) - * limit — Items per page (default 25, max 100) + * @openapi + * /guilds/{id}/tickets: + * get: + * tags: + * - Tickets + * summary: List tickets + * description: Returns paginated tickets with optional status and user filters. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: query + * name: status + * schema: + * type: string + * enum: [open, closed] + * - in: query + * name: user + * schema: + * type: string + * description: Filter by user ID + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * responses: + * "200": + * description: Paginated ticket list + * content: + * application/json: + * schema: + * type: object + * properties: + * tickets: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * guild_id: + * type: string + * user_id: + * type: string + * topic: + * type: string + * nullable: true + * status: + * type: string + * enum: [open, closed] + * thread_id: + * type: string + * nullable: true + * channel_id: + * type: string + * nullable: true + * closed_by: + * type: string + * nullable: true + * close_reason: + * type: string + * nullable: true + * created_at: + * type: string + * format: date-time + * closed_at: + * type: string + * format: date-time + * nullable: true + * total: + * type: integer + * page: + * type: integer + * limit: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" */ router.get('/:id/tickets', ticketRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { const { id: guildId } = req.params; diff --git a/src/api/routes/webhooks.js b/src/api/routes/webhooks.js index 391f9612..7776a859 100644 --- a/src/api/routes/webhooks.js +++ b/src/api/routes/webhooks.js @@ -12,12 +12,60 @@ import { validateConfigPatchBody } from '../utils/validateConfigPatch.js'; const router = Router(); /** - * POST /config-update — Receive a config update pushed from the dashboard. - * Persists the change via setConfigValue. - * - * Body: { guildId: "123456", path: "ai.model", value: "claude-3" } - * - * Auth: API secret only (req.authMethod === 'api-secret'). + * @openapi + * /webhooks/config-update: + * post: + * tags: + * - Webhooks + * summary: Push config update + * description: > + * Receives a config update pushed from the dashboard. Persists the change + * to the specified guild's config. Requires API secret authentication only + * (OAuth is not accepted). + * security: + * - ApiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - guildId + * - path + * - value + * properties: + * guildId: + * type: string + * description: Target guild ID + * path: + * type: string + * description: Dot-notated config path (e.g. "ai.model") + * value: + * description: New value to set + * responses: + * "200": + * description: Updated config section + * content: + * application/json: + * schema: + * type: object + * "400": + * description: Invalid request body + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/ValidationError" + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * description: Requires API secret authentication (OAuth not accepted) + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Error" + * "500": + * $ref: "#/components/responses/ServerError" */ router.post('/config-update', async (req, res) => { if (req.authMethod !== 'api-secret') { diff --git a/src/api/server.js b/src/api/server.js index 31b85bc2..39049a4b 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -8,6 +8,7 @@ import { error, info, warn } from '../logger.js'; import apiRouter from './index.js'; import { rateLimit } from './middleware/rateLimit.js'; import { stopAuthCleanup } from './routes/auth.js'; +import { swaggerSpec } from './swagger.js'; import { stopGuildCacheCleanup } from './utils/discordApi.js'; import { setupLogStream, stopLogStream } from './ws/logStream.js'; @@ -64,6 +65,9 @@ export function createApp(client, dbPool) { rateLimiter = rateLimit(); app.use(rateLimiter); + // Raw OpenAPI spec (JSON) — public for Mintlify + app.get('/api/docs.json', (_req, res) => res.json(swaggerSpec)); + // Mount API routes under /api/v1 app.use('/api/v1', apiRouter); diff --git a/src/api/swagger.js b/src/api/swagger.js new file mode 100644 index 00000000..71c353e8 --- /dev/null +++ b/src/api/swagger.js @@ -0,0 +1,143 @@ +/** + * OpenAPI / Swagger Configuration + * Generates the OpenAPI 3.0 spec from JSDoc annotations across route files. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import swaggerJsdoc from 'swagger-jsdoc'; +import pkg from '../../package.json' with { type: 'json' }; + +const { version } = pkg; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const options = { + definition: { + openapi: '3.0.3', + info: { + title: 'Volvox Bot API', + version, + description: + 'REST API for the Volvox Discord bot — guild management, moderation, analytics, AI conversations, and more.', + }, + servers: [{ url: '/api/v1' }], + components: { + securitySchemes: { + ApiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'x-api-secret', + description: 'Shared API secret configured via BOT_API_SECRET environment variable.', + }, + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'JWT token provided in the Authorization header as Bearer .', + }, + }, + schemas: { + Error: { + type: 'object', + properties: { + error: { type: 'string', description: 'Human-readable error message' }, + }, + required: ['error'], + }, + ValidationError: { + type: 'object', + properties: { + error: { type: 'string' }, + details: { + type: 'array', + items: { type: 'string' }, + description: 'Individual validation failure messages', + }, + }, + required: ['error'], + }, + PaginatedResponse: { + type: 'object', + properties: { + total: { type: 'integer', description: 'Total number of items' }, + page: { type: 'integer', description: 'Current page number' }, + limit: { type: 'integer', description: 'Items per page' }, + }, + }, + }, + headers: { + 'X-RateLimit-Limit': { + description: 'Maximum number of requests allowed in the window', + schema: { type: 'integer' }, + }, + 'X-RateLimit-Remaining': { + description: 'Number of requests remaining in the current window', + schema: { type: 'integer' }, + }, + 'X-RateLimit-Reset': { + description: 'Unix timestamp (seconds) when the rate limit window resets', + schema: { type: 'number' }, + }, + }, + responses: { + Unauthorized: { + description: 'Missing or invalid authentication', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + Forbidden: { + description: 'Insufficient permissions', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + NotFound: { + description: 'Resource not found', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + RateLimited: { + description: 'Rate limit exceeded', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + headers: { + 'X-RateLimit-Limit': { $ref: '#/components/headers/X-RateLimit-Limit' }, + 'X-RateLimit-Remaining': { $ref: '#/components/headers/X-RateLimit-Remaining' }, + 'X-RateLimit-Reset': { $ref: '#/components/headers/X-RateLimit-Reset' }, + }, + }, + ServerError: { + description: 'Internal server error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + ServiceUnavailable: { + description: 'Database or external service unavailable', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, + apis: [path.resolve(__dirname, 'routes/*.js')], +}; + +/** Generated OpenAPI specification object */ +export const swaggerSpec = swaggerJsdoc(options); diff --git a/tests/api/routes/moderation.test.js b/tests/api/routes/moderation.test.js index 9d81041b..80c7f5b0 100644 --- a/tests/api/routes/moderation.test.js +++ b/tests/api/routes/moderation.test.js @@ -79,6 +79,7 @@ describe('moderation routes', () => { expect(res.body.cases).toHaveLength(1); expect(res.body.total).toBe(1); expect(res.body.page).toBe(1); + expect(res.body.limit).toBe(25); expect(res.body.pages).toBe(1); }); @@ -140,6 +141,7 @@ describe('moderation routes', () => { expect(res.status).toBe(200); expect(res.body.page).toBe(2); + expect(res.body.limit).toBe(10); expect(res.body.pages).toBe(10); }); @@ -154,6 +156,7 @@ describe('moderation routes', () => { ); expect(res.status).toBe(200); + expect(res.body.limit).toBe(100); const firstCall = mockPool.query.mock.calls[0]; expect(firstCall[1]).toContain(100); }); @@ -369,6 +372,7 @@ describe('moderation routes', () => { expect(res.status).toBe(200); expect(res.body.page).toBe(3); + expect(res.body.limit).toBe(10); expect(res.body.pages).toBe(5); }); diff --git a/tests/api/swagger.test.js b/tests/api/swagger.test.js new file mode 100644 index 00000000..3c81ed74 --- /dev/null +++ b/tests/api/swagger.test.js @@ -0,0 +1,87 @@ +import request from 'supertest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/utils/logQuery.js', () => ({ + queryLogs: vi.fn().mockResolvedValue({ rows: [], total: 0 }), +})); + +vi.mock('../../src/utils/restartTracker.js', () => { + throw new Error('Module not found'); +}); + +import { createApp } from '../../src/api/server.js'; +import { swaggerSpec } from '../../src/api/swagger.js'; + +describe('OpenAPI / Swagger', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + function buildApp() { + const client = { + guilds: { cache: new Map([['guild1', {}]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + return createApp(client, null); + } + + describe('swagger spec validation', () => { + it('should generate a valid OpenAPI spec with paths', () => { + expect(swaggerSpec).toBeDefined(); + expect(swaggerSpec.openapi).toBe('3.0.3'); + expect(swaggerSpec.info.title).toBe('Volvox Bot API'); + expect(swaggerSpec.paths).toBeDefined(); + expect(Object.keys(swaggerSpec.paths).length).toBeGreaterThan(0); + }); + + it('should include security schemes', () => { + expect(swaggerSpec.components.securitySchemes.ApiKeyAuth).toBeDefined(); + expect(swaggerSpec.components.securitySchemes.BearerAuth).toBeDefined(); + }); + + it('should include common schemas', () => { + expect(swaggerSpec.components.schemas.Error).toBeDefined(); + expect(swaggerSpec.components.schemas.ValidationError).toBeDefined(); + expect(swaggerSpec.components.schemas.PaginatedResponse).toBeDefined(); + }); + + it('should include rate limit headers', () => { + expect(swaggerSpec.components.headers['X-RateLimit-Limit']).toBeDefined(); + expect(swaggerSpec.components.headers['X-RateLimit-Remaining']).toBeDefined(); + expect(swaggerSpec.components.headers['X-RateLimit-Reset']).toBeDefined(); + }); + + it('should document all major route groups', () => { + const paths = Object.keys(swaggerSpec.paths); + expect(paths.some((p) => p.startsWith('/health'))).toBe(true); + expect(paths.some((p) => p.startsWith('/auth/'))).toBe(true); + expect(paths.some((p) => p.startsWith('/community/'))).toBe(true); + expect(paths.some((p) => p.startsWith('/config'))).toBe(true); + expect(paths.some((p) => p.startsWith('/guilds'))).toBe(true); + expect(paths.some((p) => p.includes('/conversations'))).toBe(true); + expect(paths.some((p) => p.includes('/members'))).toBe(true); + expect(paths.some((p) => p.startsWith('/moderation/'))).toBe(true); + expect(paths.some((p) => p.includes('/tickets'))).toBe(true); + expect(paths.some((p) => p.startsWith('/webhooks/'))).toBe(true); + }); + }); + + describe('/api/docs.json endpoint', () => { + it('should return the OpenAPI spec as JSON without authentication (public)', async () => { + const app = buildApp(); + const res = await request(app).get('/api/docs.json'); + expect(res.status).toBe(200); + expect(res.body.openapi).toBe('3.0.3'); + expect(res.body.paths).toBeDefined(); + expect(Object.keys(res.body.paths).length).toBeGreaterThan(0); + }); + }); +});