Audit and fix Swagger/OpenAPI accuracy; regenerate served spec
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
@@ -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" } } } } */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user