Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)

Generate an OpenAPI 3.0 spec from route annotations and serve it with
Swagger UI so the full REST API is browsable and testable.

- Add swagger-ui-express (runtime) and swagger-autogen (dev) deps, plus
  an `npm run swagger` script.
- server/swagger/swagger.js: generator config with API metadata, servers,
  14 tag groups, cookie + bearer security schemes, and 28 reusable
  component schemas. Follows the Express mount chain from src/app.js so
  generated paths are fully-qualified (/api/v1/...).
- Annotate every route (auth, mobile, sso, public, admin, health) with
  #swagger tags/summaries/parameters/request bodies/security and the
  actual response codes each handler returns (400/401/403/404/409/429/
  302/502, multipart uploads).
- Serve Swagger UI at /api/docs and the raw spec at /api/docs.json,
  guarded so a missing spec disables docs instead of crashing.
- Commit the generated swagger-output.json so docs work with no build
  step; swagger-autogen stays dev-only and is not needed at runtime.
- README: new "API documentation (Swagger)" section plus tech-stack and
  project-structure entries.

Covers 51 paths / 64 operations. Existing test suite (83) still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 15:28:13 -05:00
parent d7fb274bad
commit a1f0675577
11 changed files with 7270 additions and 45 deletions

View File

@@ -26,6 +26,14 @@ const loginGuards = [backoffGuard, slowLogin, loginLimiter]
authRouter.post(
'/login',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Log in with username and password'
// #swagger.description = 'On success sets the httpOnly session cookie. If the account has 2FA enabled, returns { totpRequired, challenge } instead and no cookie is set — complete login at POST /login/totp. Rate limited and behind bot/backoff guards.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/LoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Session issued, or TOTP challenge required', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Incorrect username or password', 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,
body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(),
@@ -39,6 +47,14 @@ authRouter.post(
// Second factor: same throttling, since it's a code-guessing surface too.
authRouter.post(
'/login/totp',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Complete login with a TOTP code'
// #swagger.description = 'Second step for 2FA accounts. Exchange the challenge from /login plus the current authenticator code for a session cookie.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpLoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #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,
body('challenge').isString().notEmpty(),
body('code').isString().trim().isLength({ min: 6, max: 8 }),
@@ -46,7 +62,22 @@ authRouter.post(
loginTotp,
)
authRouter.post('/logout', logout)
authRouter.get('/me', isLoggedIn, me)
authRouter.post(
'/logout',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Log out (clear the session cookie)'
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
logout,
)
authRouter.get(
'/me',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Current authenticated user'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The signed-in user', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User" } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
isLoggedIn,
me,
)
module.exports = authRouter

View File

@@ -17,6 +17,14 @@ const loginGuards = [backoffGuard, slowLogin, loginLimiter]
// POST /auth/mobile/login — { username, password, code? }
mobileRouter.post(
'/login',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Native login → access + refresh tokens'
// #swagger.description = 'Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileLoginRequest" } } } } */
/* #swagger.responses[200] = { description: 'Access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid credentials, or a TOTP code is required', 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,
body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(),
@@ -29,6 +37,14 @@ mobileRouter.post(
// POST /auth/mobile/refresh — { refreshToken }
mobileRouter.post(
'/refresh',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Rotate a refresh token for a fresh token pair'
// #swagger.description = 'Refresh tokens are single-use: the presented token is revoked and a new access + refresh pair is issued. Reusing a rotated token fails with 401.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileRefreshRequest" } } } } */
/* #swagger.responses[200] = { description: 'New access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid or expired session', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many refresh attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
mobileRefreshLimiter,
body('refreshToken').isString().notEmpty(),
validate,
@@ -38,6 +54,13 @@ mobileRouter.post(
// POST /auth/mobile/logout — { refreshToken?, all? } — requires a valid bearer.
mobileRouter.post(
'/logout',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Revoke the current (or all) refresh tokens'
// #swagger.description = 'Requires a valid bearer access token. Revokes the given refresh token, or every session for the user when { all: true }. Idempotent.'
// #swagger.security = [{ "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/MobileLogoutRequest" } } } } */
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[401] = { description: 'Missing or invalid bearer token', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
requireAuth,
body('refreshToken').optional().isString(),
body('all').optional().isBoolean(),

View File

@@ -7,16 +7,54 @@ const { ssoStartLimiter } = require('../../../middleware/rateLimit')
const ssoRouter = express.Router()
// Public discovery — the login page reads this to render provider buttons.
ssoRouter.get('/providers', ctrl.listProviders)
ssoRouter.get(
'/providers',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'List enabled SSO providers'
// #swagger.description = 'Public discovery used by the login page to render provider buttons. Never exposes secrets.'
/* #swagger.responses[200] = { description: 'Enabled, valid providers', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Provider" } } } } } */
ctrl.listProviders,
)
// Begin login (public) — redirects to the IdP.
ssoRouter.get('/sso/:provider/start', ssoStartLimiter, ctrl.start)
ssoRouter.get(
'/sso/:provider/start',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'Begin SSO login (redirect to the IdP)'
// #swagger.description = 'Sets a short-lived signed transaction cookie and 302-redirects to the provider authorize URL. On error redirects back to the login page with an sso_error query param.'
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
// #swagger.parameters['returnTo'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Internal /admin path to return to after login.' }
/* #swagger.responses[302] = { description: 'Redirect to the identity provider (or back to the login page on error)' } */
ssoStartLimiter,
ctrl.start,
)
// Begin account linking (must be signed in — the tx captures the acting user).
ssoRouter.get('/sso/:provider/link', requireAuth, ctrl.linkStart)
ssoRouter.get(
'/sso/:provider/link',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'Begin linking an SSO identity to the current account'
// #swagger.description = 'Requires an authenticated session; the signed transaction captures the acting user so the callback can attach the external identity.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
/* #swagger.responses[302] = { description: 'Redirect to the identity provider (or back to the account page on error)' } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
requireAuth,
ctrl.linkStart,
)
// OAuth redirect target — completes login or linking. Not behind requireAuth:
// the signed tx cookie authorizes link mode; login mode is link-only anyway.
ssoRouter.get('/sso/:provider/callback', ctrl.callback)
ssoRouter.get(
'/sso/:provider/callback',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'OAuth redirect target — completes login or linking'
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).'
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code.' }
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie).' }
/* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
ctrl.callback,
)
module.exports = ssoRouter