From f8db61025bfd7723d2215c073264be5eea322d40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:51:16 -0500 Subject: [PATCH] Audit and fix Swagger/OpenAPI accuracy; regenerate served spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route-level annotations were 100% present, but the committed/served spec (swagger-output.json) was stale and several response schemas had drifted from the controllers. This aligns the docs with actual behavior and regenerates the spec. Served spec was stale (64/67 operations). Regenerating picks up three routes that were added after the last generation: - POST /api/v1/auth/sso/totp - GET /api/v1/admin/discord-bot/config - PUT /api/v1/admin/discord-bot/config plus a stale /auth/logout summary. Response-shape corrections (annotation now matches controller output): - Mutation endpoints do NOT return the generic { message } envelope. Deletes echo { id } / { slug }; toggles return { deleted }, { unlinked }, { totp_enabled }, or { ip, removed }. Documented as-is via new DeletedId/DeletedSlug/DeletedFlag/UnlinkedFlag/TotpState/ UnbanResult components. (The API is intentionally inconsistent here; recorded rather than normalized — see follow-up note.) - POST /account/totp/setup: otpauth_url -> otpauthUrl (TotpSetup) - PUT /admin/site-mode: { mode } -> { site_mode, changed_at, changed_by } - GET /account: full User -> AccountStatus (id/username/role/totp_enabled) - GET /account/identities: add linked_at (LinkedIdentity) - GET /public/status: add status_message (PublicStatus) - POST /auth/sso/totp: user is SafeUser, not full User - GET /dashboard: description/shape corrected (posts+users, no wiki) Schema completeness: - Provider (public discovery): { id, name, icon, loginUrl, priority }, not { id, name, kind } - ProviderConfig: add hasSecret, builtin, health (ProviderHealth) - Post: add excerpt, author_id, published_at - MobileTokenResponse.expiresIn: duration string ("15m"), not integer Config: declare the Admin · Discord Bot tag (was used but undeclared). Auth model and the internal/external boundary were verified correct and left unchanged: cookie + bearer are both accepted on session routes (dual security annotations are accurate), and /internal/* runs on a separate listener already excluded from the scan. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV --- server/src/router/v1/admin/admin.routes.js | 28 +- server/src/router/v1/auth/sso.routes.js | 2 +- server/src/router/v1/public/public.routes.js | 2 +- server/swagger/swagger-output.json | 1024 ++++++++++++++++-- server/swagger/swagger.js | 128 ++- 5 files changed, 1098 insertions(+), 86 deletions(-) diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index febd012..c279c62 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -30,7 +30,7 @@ adminRouter.get( // #swagger.tags = ['Admin · Account'] // #swagger.summary = 'Get the current account (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ + /* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.getAccount, ) @@ -39,7 +39,7 @@ adminRouter.post( // #swagger.tags = ['Admin · Account'] // #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { type: "object", properties: { otpauth_url: { type: "string" }, qr: { type: "string" } } } } } } */ + /* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.totpSetup, @@ -50,7 +50,7 @@ adminRouter.post( // #swagger.summary = 'Enable 2FA by confirming a code' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ - /* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ /* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ @@ -64,7 +64,7 @@ adminRouter.post( // #swagger.summary = 'Disable 2FA by confirming a code' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ - /* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ /* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ body('code').isString().trim().isLength({ min: 6, max: 8 }), @@ -78,7 +78,7 @@ adminRouter.get( // #swagger.tags = ['Admin · Account'] // #swagger.summary = 'List linked SSO identities (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { type: "object", properties: { provider: { type: "string" }, email: { type: "string" } } } } } } } */ + /* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.listIdentities, ) @@ -88,7 +88,7 @@ adminRouter.delete( // #swagger.summary = 'Unlink an SSO identity (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('provider').matches(/^[a-z0-9-]+$/), @@ -136,7 +136,7 @@ adminRouter.get( // #swagger.tags = ['Admin · Dashboard'] // #swagger.summary = 'Dashboard summary counts' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Summary counts (posts, wiki, users, site mode)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ ctrl.dashboard, ) @@ -147,7 +147,7 @@ adminRouter.put( // #swagger.description = 'Switch the site between live and maintenance.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", example: "maintenance" } } } } } } */ + /* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeState" } } } } */ /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ @@ -257,7 +257,7 @@ adminRouter.delete( // #swagger.summary = 'Delete a post' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } - /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('id').isInt(), @@ -318,7 +318,7 @@ adminRouter.delete( // #swagger.summary = 'Delete a wiki category' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' } - /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('id').isInt(), @@ -457,7 +457,7 @@ adminRouter.delete( // #swagger.summary = 'Delete a wiki page' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' } - /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: 'Deleted (echoes the slug)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedSlug" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ ctrl.deleteWiki, @@ -521,7 +521,7 @@ adminRouter.post( // #swagger.summary = 'Emergency unban an IP (admin only)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: 'Unbanned (echoes the ip and whether an entry was cleared)', content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanResult" } } } } */ /* #swagger.responses[400] = { description: 'Invalid IP', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ @@ -636,7 +636,7 @@ adminRouter.delete( // #swagger.description = 'Built-in providers cannot be deleted — disable them instead.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */ /* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ @@ -702,7 +702,7 @@ adminRouter.delete( // #swagger.summary = 'Delete a user (admin only)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */ + /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ /* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ diff --git a/server/src/router/v1/auth/sso.routes.js b/server/src/router/v1/auth/sso.routes.js index bf30848..f224e82 100644 --- a/server/src/router/v1/auth/sso.routes.js +++ b/server/src/router/v1/auth/sso.routes.js @@ -73,7 +73,7 @@ ssoRouter.post( // #swagger.summary = 'Complete an SSO login with a TOTP code' // #swagger.description = 'Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.' /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["code"], properties: { code: { type: "string" } } } } } } */ - /* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User" }, returnTo: { type: "string" } } } } } } */ + /* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/SafeUser" }, returnTo: { type: "string" } } } } } } */ /* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ ...loginGuards, diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index 65a3f2f..80927dd 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -22,7 +22,7 @@ publicRouter.get( // #swagger.tags = ['Public'] // #swagger.summary = 'Site mode / status' // #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page.' - /* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", example: "live" } } } } } } */ + /* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicStatus" } } } } */ ctrl.getStatus, ) publicRouter.post( diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 487efff..24af2c2 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -64,6 +64,10 @@ "name": "Admin · Bot Activity", "description": "Bot-scoring/ban state and emergency unban (admin only)" }, + { + "name": "Admin · Discord Bot", + "description": "Discord bot token/config and live status (admin only)" + }, { "name": "Admin · Auth Providers", "description": "SSO provider configuration (admin only)" @@ -234,7 +238,7 @@ "tags": [ "Auth" ], - "summary": "Log out (clear the session cookie)", + "summary": "Log out (clear the cookie and revoke this session)", "description": "", "responses": { "200": { @@ -633,6 +637,79 @@ } } }, + "/api/v1/auth/sso/totp": { + "post": { + "tags": [ + "Auth · SSO" + ], + "summary": "Complete an SSO login with a TOTP code", + "description": "Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.", + "responses": { + "200": { + "description": "Session issued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/SafeUser" + }, + "returnTo": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Invalid code or expired challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts (rate limited / backoff)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code" + ], + "properties": { + "code": { + "type": "string" + } + } + } + } + } + } + } + }, "/api/v1/public/settings": { "get": { "tags": [ @@ -671,13 +748,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "mode": { - "type": "string", - "example": "live" - } - } + "$ref": "#/components/schemas/PublicStatus" } } } @@ -1031,7 +1102,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/AccountStatus" } } } @@ -1070,15 +1141,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "otpauth_url": { - "type": "string" - }, - "qr": { - "type": "string" - } - } + "$ref": "#/components/schemas/TotpSetup" } } } @@ -1130,7 +1193,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/TotpState" } } } @@ -1202,7 +1265,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/TotpState" } } } @@ -1266,15 +1329,7 @@ "schema": { "type": "array", "items": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "email": { - "type": "string" - } - } + "$ref": "#/components/schemas/LinkedIdentity" } } } @@ -1328,7 +1383,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/UnlinkedFlag" } } } @@ -1379,12 +1434,49 @@ "description": "", "responses": { "200": { - "description": "Summary counts (posts, wiki, users, site mode)", + "description": "Summary: site mode, last change, post/user counts and recent activity", "content": { "application/json": { "schema": { "type": "object", - "additionalProperties": true + "properties": { + "site_mode": { + "type": "string", + "example": "live" + }, + "last_change": { + "type": "object", + "properties": { + "at": { + "type": "string", + "nullable": true + }, + "by": { + "type": "string", + "nullable": true + } + } + }, + "counts": { + "type": "object", + "properties": { + "posts": { + "type": "object", + "additionalProperties": true + }, + "users": { + "type": "integer" + } + } + }, + "recent_activity": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } } } } @@ -1426,13 +1518,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "mode": { - "type": "string", - "example": "maintenance" - } - } + "$ref": "#/components/schemas/SiteModeState" } } } @@ -1910,11 +1996,11 @@ ], "responses": { "200": { - "description": "Deleted", + "description": "Deleted (echoes the id)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/DeletedId" } } } @@ -2264,11 +2350,11 @@ ], "responses": { "200": { - "description": "Deleted", + "description": "Deleted (echoes the id)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/DeletedId" } } } @@ -2674,11 +2760,11 @@ ], "responses": { "200": { - "description": "Deleted", + "description": "Deleted (echoes the slug)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/DeletedSlug" } } } @@ -3267,11 +3353,11 @@ "description": "", "responses": { "200": { - "description": "Unbanned", + "description": "Unbanned (echoes the ip and whether an entry was cleared)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/UnbanResult" } } } @@ -3327,6 +3413,141 @@ } } }, + "/api/v1/admin/discord-bot/config": { + "get": { + "tags": [ + "Admin · Discord Bot" + ], + "summary": "Get Discord bot config + live status (admin only)", + "description": "", + "responses": { + "200": { + "description": "Masked config + live status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Discord Bot" + ], + "summary": "Save Discord bot config (admin only)", + "description": "token is write-only — omit/blank it to keep the existing one unchanged.", + "responses": { + "200": { + "description": "Updated config + live status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Validation error, invalid token, or missing token while enabling", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "guildId": { + "type": "string" + }, + "token": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + } + } + }, "/api/v1/admin/auth/providers": { "get": { "tags": [ @@ -3576,7 +3797,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/DeletedFlag" } } } @@ -3890,11 +4111,11 @@ ], "responses": { "200": { - "description": "Deleted", + "description": "Deleted (echoes the id)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/DeletedId" } } } @@ -4429,15 +4650,15 @@ "properties": { "type": { "type": "string", - "example": "integer" + "example": "string" }, "description": { "type": "string", - "example": "Access token lifetime in seconds." + "example": "Access token lifetime as a duration string (zeit/ms format, e.g. \"15m\")." }, "example": { - "type": "number", - "example": 900 + "type": "string", + "example": "15m" } } }, @@ -4661,26 +4882,54 @@ } } }, - "kind": { + "icon": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, - "enum": { - "type": "array", - "example": [ - "oidc", - "oauth2" - ], - "items": { - "type": "string" - } + "description": { + "type": "string", + "example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')." }, "example": { "type": "string", - "example": "oidc" + "example": "google" + } + } + }, + "loginUrl": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Relative URL to begin the redirect flow." + }, + "example": { + "type": "string", + "example": "/api/v1/auth/sso/google/start" + } + } + }, + "priority": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Sort order (ascending)." + }, + "example": { + "type": "number", + "example": 1 } } } @@ -4721,6 +4970,8 @@ "enum": { "type": "array", "example": [ + "google", + "discord", "oidc", "oauth2" ], @@ -4769,6 +5020,23 @@ } } }, + "hasSecret": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether a client secret is stored (the secret itself is never returned)." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, "authorizeUrl": { "type": "object", "properties": { @@ -4833,6 +5101,84 @@ "example": 10 } } + }, + "builtin": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True for the fixed google/discord providers." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "health": { + "$ref": "#/components/schemas/ProviderHealth" + } + } + } + } + }, + "ProviderHealth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Config-completeness check that gates whether a provider is offered to end users." + }, + "properties": { + "type": "object", + "properties": { + "valid": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "missing": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Names of required config fields that are still missing." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [], + "items": {} + } + } } } } @@ -5086,12 +5432,29 @@ } } }, + "excerpt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, "body": { "type": "object", "properties": { "type": { "type": "string", "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true } } }, @@ -5102,6 +5465,10 @@ "type": "string", "example": "string" }, + "nullable": { + "type": "boolean", + "example": true + }, "example": { "type": "string", "example": "/uploads/1700000000-abcd.png" @@ -5121,6 +5488,23 @@ } } }, + "author_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1 + } + } + }, "created_at": { "type": "object", "properties": { @@ -5146,6 +5530,23 @@ "example": "date-time" } } + }, + "published_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } } } } @@ -6053,6 +6454,499 @@ } } } + }, + "AccountStatus": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Self-service account security status (GET /admin/account)." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1 + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "role": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "admin", + "editor" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "totp_enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TotpSetup": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Enrollment material returned by POST /account/totp/setup." + }, + "properties": { + "type": "object", + "properties": { + "otpauthUrl": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "otpauth://totp/UOMysticmoon:admin?secret=..." + } + } + }, + "qr": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "QR code as a data: URL." + }, + "example": { + "type": "string", + "example": "data:image/png;base64,iVBORw0KGgo..." + } + } + } + } + } + } + }, + "TotpState": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Result of enabling/disabling 2FA." + }, + "properties": { + "type": "object", + "properties": { + "totp_enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "LinkedIdentity": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "provider": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "google" + } + } + }, + "email": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "email" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "user@example.com" + } + } + }, + "linked_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "SiteModeState": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Result of PUT /admin/site-mode." + }, + "properties": { + "type": "object", + "properties": { + "site_mode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "live", + "maintenance" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "maintenance" + } + } + }, + "changed_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "changed_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "admin" + } + } + } + } + } + } + }, + "PublicStatus": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Public site status (GET /public/status)." + }, + "properties": { + "type": "object", + "properties": { + "mode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "live", + "maintenance" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "live" + } + } + }, + "status_message": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "" + } + } + } + } + } + } + }, + "DeletedId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + } + } + } + } + }, + "DeletedSlug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "getting-started" + } + } + } + } + } + } + }, + "DeletedFlag": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "UnlinkedFlag": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "unlinked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "UnbanResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "ip": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "203.0.113.5" + } + } + }, + "removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether the IP had an entry that was cleared." + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index af1c289..f33600c 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -53,6 +53,7 @@ const doc = { { name: 'Admin · Settings', description: 'Site settings (admin only)' }, { name: 'Admin · Activity', description: 'Admin activity log' }, { name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' }, + { name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' }, { name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' }, { name: 'Admin · Users', description: 'User management (admin only)' }, ], @@ -144,7 +145,11 @@ const doc = { properties: { accessToken: { type: 'string', description: 'Short-lived bearer JWT.' }, refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' }, - expiresIn: { type: 'integer', description: 'Access token lifetime in seconds.', example: 900 }, + expiresIn: { + type: 'string', + description: 'Access token lifetime as a duration string (zeit/ms format, e.g. "15m").', + example: '15m', + }, user: { $ref: '#/components/schemas/SafeUser' }, }, }, @@ -173,27 +178,59 @@ const doc = { name: { type: 'string', maxLength: 100, example: 'Lord British' }, }, }, + // Public discovery shape (GET /auth/providers) — enough for the login page + // to render a button and start the flow. Never exposes secrets or endpoints. Provider: { type: 'object', properties: { id: { type: 'string', example: 'google' }, name: { type: 'string', example: 'Google' }, - kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' }, + icon: { + type: 'string', + description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').", + example: 'google', + }, + loginUrl: { + type: 'string', + description: 'Relative URL to begin the redirect flow.', + example: '/api/v1/auth/sso/google/start', + }, + priority: { type: 'integer', description: 'Sort order (ascending).', example: 1 }, }, }, + // Admin-facing provider config (GET/POST/PUT /admin/auth/providers). The + // client secret is write-only and NEVER returned — `hasSecret` reports + // whether one is stored. `builtin` marks google/discord (fixed kind/name), + // and `health` is the config-completeness check used to gate visibility. ProviderConfig: { type: 'object', properties: { id: { type: 'string', example: 'okta' }, - kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' }, + kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' }, name: { type: 'string', example: 'Okta' }, enabled: { type: 'boolean', example: true }, clientId: { type: 'string' }, + hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true }, authorizeUrl: { type: 'string', format: 'uri' }, tokenUrl: { type: 'string', format: 'uri' }, userinfoUrl: { type: 'string', format: 'uri' }, scopes: { type: 'string', example: 'openid email profile' }, priority: { type: 'integer', example: 10 }, + builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false }, + health: { $ref: '#/components/schemas/ProviderHealth' }, + }, + }, + ProviderHealth: { + type: 'object', + description: 'Config-completeness check that gates whether a provider is offered to end users.', + properties: { + valid: { type: 'boolean', example: true }, + missing: { + type: 'array', + description: 'Names of required config fields that are still missing.', + items: { type: 'string' }, + example: [], + }, }, }, ProviderCreateRequest: { @@ -220,11 +257,14 @@ const doc = { category: { type: 'string', example: 'news' }, title: { type: 'string', example: 'Server maintenance this weekend' }, slug: { type: 'string', example: 'server-maintenance-this-weekend' }, - body: { type: 'string' }, - image_url: { type: 'string', example: '/uploads/1700000000-abcd.png' }, + excerpt: { type: 'string', nullable: true }, + body: { type: 'string', nullable: true }, + image_url: { type: 'string', nullable: true, example: '/uploads/1700000000-abcd.png' }, published: { type: 'boolean', example: true }, + author_id: { type: 'integer', nullable: true, example: 1 }, created_at: { type: 'string', format: 'date-time' }, updated_at: { type: 'string', format: 'date-time' }, + published_at: { type: 'string', format: 'date-time', nullable: true }, }, }, PostCreateRequest: { @@ -330,6 +370,84 @@ const doc = { required: ['ip'], properties: { ip: { type: 'string', example: '203.0.113.5' } }, }, + // ── Actual mutation-response shapes ───────────────────────────────────── + // These endpoints do NOT return the generic { message } envelope; they echo + // the affected resource id/slug or a boolean flag. Documented here as-is so + // the spec matches the controllers. (The shapes are intentionally recorded + // rather than normalized — see the audit note if standardizing later.) + AccountStatus: { + type: 'object', + description: 'Self-service account security status (GET /admin/account).', + properties: { + id: { type: 'integer', example: 1 }, + username: { type: 'string', example: 'admin' }, + role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' }, + totp_enabled: { type: 'boolean', example: true }, + }, + }, + TotpSetup: { + type: 'object', + description: 'Enrollment material returned by POST /account/totp/setup.', + properties: { + otpauthUrl: { type: 'string', example: 'otpauth://totp/UOMysticmoon:admin?secret=...' }, + qr: { type: 'string', description: 'QR code as a data: URL.', example: 'data:image/png;base64,iVBORw0KGgo...' }, + }, + }, + TotpState: { + type: 'object', + description: 'Result of enabling/disabling 2FA.', + properties: { totp_enabled: { type: 'boolean', example: true } }, + }, + LinkedIdentity: { + type: 'object', + properties: { + provider: { type: 'string', example: 'google' }, + email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' }, + linked_at: { type: 'string', format: 'date-time' }, + }, + }, + SiteModeState: { + type: 'object', + description: 'Result of PUT /admin/site-mode.', + properties: { + site_mode: { type: 'string', enum: ['live', 'maintenance'], example: 'maintenance' }, + changed_at: { type: 'string', format: 'date-time' }, + changed_by: { type: 'string', example: 'admin' }, + }, + }, + PublicStatus: { + type: 'object', + description: 'Public site status (GET /public/status).', + properties: { + mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' }, + status_message: { type: 'string', example: '' }, + }, + }, + // Delete/mutation acknowledgements — each echoes the affected resource key + // or a boolean flag rather than a { message } string. + DeletedId: { + type: 'object', + properties: { id: { type: 'integer', example: 12 } }, + }, + DeletedSlug: { + type: 'object', + properties: { slug: { type: 'string', example: 'getting-started' } }, + }, + DeletedFlag: { + type: 'object', + properties: { deleted: { type: 'boolean', example: true } }, + }, + UnlinkedFlag: { + type: 'object', + properties: { unlinked: { type: 'boolean', example: true } }, + }, + UnbanResult: { + type: 'object', + properties: { + ip: { type: 'string', example: '203.0.113.5' }, + removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true }, + }, + }, }, }, }