diff --git a/apps/server/package.json b/apps/server/package.json index d748e991..fb6c8f84 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -59,6 +59,7 @@ "@nestjs/platform-fastify": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.19", "@nestjs/schedule": "^6.1.3", + "@nestjs/swagger": "^11.4.2", "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.19", @@ -93,6 +94,7 @@ "nestjs-cls": "^6.2.0", "nestjs-kysely": "^3.1.2", "nestjs-pino": "^4.6.1", + "nestjs-zod": "^5.3.0", "nodemailer": "^8.0.5", "openid-client": "^6.8.2", "otpauth": "^9.5.0", diff --git a/apps/server/src/core/acadenice/api-keys/controllers/api-key.controller.ts b/apps/server/src/core/acadenice/api-keys/controllers/api-key.controller.ts index ee73f68e..0c65223c 100644 --- a/apps/server/src/core/acadenice/api-keys/controllers/api-key.controller.ts +++ b/apps/server/src/core/acadenice/api-keys/controllers/api-key.controller.ts @@ -11,6 +11,14 @@ import { Body, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -29,11 +37,16 @@ import { CreateApiKeySchema } from '../dto/api-key.dto'; * POST /api/v1/api-keys Create token — returns plain once * DELETE /api/v1/api-keys/:id Revoke */ +@ApiTags('api-keys') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/api-keys') export class AcadeniceApiKeyController { constructor(private readonly apiKeyService: AcadeniceApiKeyService) {} + @ApiOperation({ summary: 'List personal API tokens', description: 'Returns all active API tokens for the authenticated user in this workspace. Token hashes are never returned.' }) + @ApiResponse({ status: 200, description: 'Array of API key metadata (no secret hashes)' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Get() async list( @AuthUser() user: User, @@ -42,6 +55,11 @@ export class AcadeniceApiKeyController { return this.apiKeyService.list(user.id, workspace.id); } + @ApiOperation({ summary: 'Create API token', description: 'Generates a new personal API token. The plain token is returned once — store it securely.' }) + @ApiBody({ schema: { type: 'object', required: ['label'], properties: { label: { type: 'string', maxLength: 120 }, durationDays: { type: 'integer', description: 'Expiry in days. Null = never expires.' } } }, description: 'Token creation payload' }) + @ApiResponse({ status: 201, description: 'Returns { token (plain, shown once), keyInfo }' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Post() @HttpCode(HttpStatus.CREATED) async create( @@ -64,6 +82,12 @@ export class AcadeniceApiKeyController { return { token: result.token, keyInfo: result.row }; } + @ApiOperation({ summary: 'Revoke API token', description: 'Permanently revokes a personal API token. The caller must own the token.' }) + @ApiParam({ name: 'id', description: 'API token UUID', type: 'string' }) + @ApiResponse({ status: 204, description: 'Token revoked' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Token does not belong to the caller' }) + @ApiResponse({ status: 404, description: 'Token not found' }) @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async revoke( diff --git a/apps/server/src/core/acadenice/audit-log/controllers/audit-log.controller.ts b/apps/server/src/core/acadenice/audit-log/controllers/audit-log.controller.ts index 06eb7d4c..d520ac26 100644 --- a/apps/server/src/core/acadenice/audit-log/controllers/audit-log.controller.ts +++ b/apps/server/src/core/acadenice/audit-log/controllers/audit-log.controller.ts @@ -6,6 +6,13 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -24,11 +31,24 @@ import { AuditLogQuerySchema } from '../dto/audit-log-query.dto'; * Auth : JWT (admin or owner only) * Query : limit, offset, userId, action, since (ISO), until (ISO) */ +@ApiTags('audit-log') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/audit-log') export class AcadeniceAuditLogController { constructor(private readonly auditLogService: AcadeniceAuditLogService) {} + @ApiOperation({ summary: 'List audit log entries', description: 'Returns paginated audit log entries for the workspace. Admin or Owner role required.' }) + @ApiQuery({ name: 'limit', required: false, type: 'number', description: 'Max results per page (default 50)' }) + @ApiQuery({ name: 'offset', required: false, type: 'number', description: 'Pagination offset' }) + @ApiQuery({ name: 'userId', required: false, type: 'string', description: 'Filter by actor user UUID' }) + @ApiQuery({ name: 'action', required: false, type: 'string', description: 'Filter by action type' }) + @ApiQuery({ name: 'since', required: false, type: 'string', description: 'ISO 8601 lower bound (inclusive)' }) + @ApiQuery({ name: 'until', required: false, type: 'string', description: 'ISO 8601 upper bound (inclusive)' }) + @ApiResponse({ status: 200, description: 'Paginated audit log page' }) + @ApiResponse({ status: 400, description: 'Invalid query params' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Admin or Owner role required' }) @Get() async list( @AuthUser() user: User, diff --git a/apps/server/src/core/acadenice/backlinks/controllers/backlinks.controller.ts b/apps/server/src/core/acadenice/backlinks/controllers/backlinks.controller.ts index 62725241..dd7c2791 100644 --- a/apps/server/src/core/acadenice/backlinks/controllers/backlinks.controller.ts +++ b/apps/server/src/core/acadenice/backlinks/controllers/backlinks.controller.ts @@ -6,6 +6,13 @@ import { ParseUUIDPipe, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -22,6 +29,8 @@ import { BacklinkService, BacklinksResult } from '../services/backlink.service'; * We do not require a special Acadenice permission here — any authenticated * workspace member can query backlinks for pages they can read. */ +@ApiTags('backlinks') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/pages') export class BacklinksController { @@ -33,6 +42,11 @@ export class BacklinksController { * The response is filtered to pages the authenticated user can read. * Returns an empty result (not 404) when no backlinks exist. */ + @ApiOperation({ summary: 'Get backlinks for a page', description: 'Returns all pages that link to the given page, grouped by link type. Filtered to pages the caller can read.' }) + @ApiParam({ name: 'pageId', description: 'Target page UUID', type: 'string' }) + @ApiResponse({ status: 200, description: 'Backlinks result grouped by link type' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Page not found' }) @Get(':pageId/backlinks') async getBacklinks( @Param('pageId', ParseUUIDPipe) pageId: string, diff --git a/apps/server/src/core/acadenice/clipper/controllers/clipper.controller.ts b/apps/server/src/core/acadenice/clipper/controllers/clipper.controller.ts index 9cb963d1..5d23269b 100644 --- a/apps/server/src/core/acadenice/clipper/controllers/clipper.controller.ts +++ b/apps/server/src/core/acadenice/clipper/controllers/clipper.controller.ts @@ -13,6 +13,15 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -57,6 +66,7 @@ const TOKEN_HEADER = 'x-clipper-token'; * GET /api/v1/clipper/tokens (JWT auth) * DELETE /api/v1/clipper/tokens/:id (JWT auth) */ +@ApiTags('clipper') @Controller('v1/clipper') export class ClipperController { constructor( @@ -68,6 +78,12 @@ export class ClipperController { // Import endpoint — authenticated via X-Clipper-Token header // --------------------------------------------------------------------------- + @ApiOperation({ summary: 'Import clipped content', description: 'Creates a page from web-clipped content. Authentication via X-Clipper-Token header (not JWT).' }) + @ApiHeader({ name: 'x-clipper-token', required: true, description: 'Clipper token (obtained from POST /v1/clipper/tokens)' }) + @ApiBody({ schema: { type: 'object', properties: { url: { type: 'string' }, title: { type: 'string' }, content: { type: 'string' }, target_workspace_id: { type: 'string', format: 'uuid' }, target_space_id: { type: 'string', format: 'uuid' } } }, description: 'Clip payload' }) + @ApiResponse({ status: 201, description: 'Clip imported, returns page info' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Missing or invalid X-Clipper-Token' }) @Post('import') @HttpCode(HttpStatus.CREATED) async import( @@ -101,6 +117,11 @@ export class ClipperController { // Token management — JWT auth (user manages their own tokens) // --------------------------------------------------------------------------- + @ApiOperation({ summary: 'Create clipper token', description: 'Generates a token for the browser clipper extension. JWT auth required.' }) + @ApiBearerAuth() + @ApiBody({ schema: { type: 'object', required: ['label'], properties: { label: { type: 'string' }, duration_days: { type: 'integer' } } }, description: 'Token creation payload' }) + @ApiResponse({ status: 201, description: 'Returns { token (plain, shown once), tokenInfo }' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @UseGuards(JwtAuthGuard) @Post('tokens') @HttpCode(HttpStatus.CREATED) @@ -129,6 +150,10 @@ export class ClipperController { return { token: result.token, tokenInfo: result.row }; } + @ApiOperation({ summary: 'List clipper tokens', description: 'Returns all clipper tokens for the caller. Secret hashes never returned.' }) + @ApiBearerAuth() + @ApiResponse({ status: 200, description: 'Array of token metadata' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @UseGuards(JwtAuthGuard) @Get('tokens') async listTokens( @@ -138,6 +163,13 @@ export class ClipperController { return this.tokenService.list(user.id, workspace.id); } + @ApiOperation({ summary: 'Revoke clipper token', description: 'Permanently deletes a clipper token. Caller must own the token.' }) + @ApiBearerAuth() + @ApiParam({ name: 'id', description: 'Clipper token UUID', type: 'string' }) + @ApiResponse({ status: 204, description: 'Token revoked' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Token does not belong to caller' }) + @ApiResponse({ status: 404, description: 'Token not found' }) @UseGuards(JwtAuthGuard) @Delete('tokens/:id') @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/server/src/core/acadenice/comments/controllers/page-comments.controller.ts b/apps/server/src/core/acadenice/comments/controllers/page-comments.controller.ts index 9d588428..ada1e343 100644 --- a/apps/server/src/core/acadenice/comments/controllers/page-comments.controller.ts +++ b/apps/server/src/core/acadenice/comments/controllers/page-comments.controller.ts @@ -6,6 +6,13 @@ import { HttpStatus, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -25,6 +32,8 @@ import { ResolvePageCommentDto } from '../dto/comment.dto'; * Endpoints: * POST /api/v1/page-comments/resolve */ +@ApiTags('comments') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/page-comments') export class PageCommentsController { @@ -32,6 +41,12 @@ export class PageCommentsController { private readonly pageCommentResolveService: PageCommentResolveService, ) {} + @ApiOperation({ summary: 'Resolve or unresolve a page comment', description: 'Toggles the resolved state of a page comment thread.' }) + @ApiBody({ schema: { type: 'object', required: ['commentId', 'resolved'], properties: { commentId: { type: 'string', format: 'uuid' }, resolved: { type: 'boolean' } } }, description: 'Resolve payload' }) + @ApiResponse({ status: 200, description: 'Comment updated' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Comment not found' }) @HttpCode(HttpStatus.OK) @Post('resolve') async resolve( diff --git a/apps/server/src/core/acadenice/graph/controllers/graph.controller.ts b/apps/server/src/core/acadenice/graph/controllers/graph.controller.ts index fbceb10a..738a5984 100644 --- a/apps/server/src/core/acadenice/graph/controllers/graph.controller.ts +++ b/apps/server/src/core/acadenice/graph/controllers/graph.controller.ts @@ -5,6 +5,13 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -25,11 +32,22 @@ import { GraphQuerySchema, GraphResponse } from '../dto/graph.dto'; * workspaceId is always resolved from the JWT context (AuthWorkspace) — * the query param is accepted but ignored to prevent cross-workspace leaks. */ +@ApiTags('graph') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/graph') export class GraphController { constructor(private readonly graphService: GraphService) {} + @ApiOperation({ summary: 'Get knowledge graph', description: 'Returns graph nodes and edges for the workspace. Filtered to pages the caller can read. parent_child edges always included.' }) + @ApiQuery({ name: 'spaceId', required: false, type: 'string', description: 'Limit graph to a specific space' }) + @ApiQuery({ name: 'pageId', required: false, type: 'string', description: 'Ego-centric graph centered on this page' }) + @ApiQuery({ name: 'depth', required: false, type: 'number', description: 'Traversal depth (default 2)' }) + @ApiQuery({ name: 'types', required: false, type: 'string', description: 'Comma-separated link types: wikilink, mention, embed, parent_child' }) + @ApiQuery({ name: 'includeOrphans', required: false, type: 'boolean', description: 'Include pages with no links' }) + @ApiResponse({ status: 200, description: 'Graph nodes and edges' }) + @ApiResponse({ status: 400, description: 'Invalid query params' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Get() async getGraph( @Query() rawQuery: Record, diff --git a/apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts b/apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts index 4cb6e5d4..dc3397a6 100644 --- a/apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts +++ b/apps/server/src/core/acadenice/notifications/controllers/notification-preferences.controller.ts @@ -8,6 +8,13 @@ import { Put, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { User } from '@docmost/db/types/entity.types'; @@ -27,6 +34,8 @@ import { ZodError } from 'zod'; * (the native Docmost JSONB column) — so changes are immediately visible to * the native notification email pipeline. */ +@ApiTags('notifications') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/notification-preferences') export class NotificationPreferencesController { @@ -34,11 +43,19 @@ export class NotificationPreferencesController { private readonly prefsService: NotificationPreferencesService, ) {} + @ApiOperation({ summary: 'Get notification preferences', description: 'Returns the notification preferences for the authenticated user.' }) + @ApiResponse({ status: 200, description: 'Notification preferences object' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Get() async getPreferences(@AuthUser() user: User) { return this.prefsService.getPreferences(user.id); } + @ApiOperation({ summary: 'Update notification preferences', description: 'Replaces the full notification preferences for the authenticated user.' }) + @ApiBody({ schema: { type: 'object', description: 'Notification preferences map (channel/event booleans)' }, description: 'Preferences payload' }) + @ApiResponse({ status: 200, description: 'Updated preferences' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Put() @HttpCode(HttpStatus.OK) async updatePreferences( diff --git a/apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts b/apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts index ba0d5c85..124568b6 100644 --- a/apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts +++ b/apps/server/src/core/acadenice/notifications/controllers/notifications.controller.ts @@ -11,6 +11,15 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { User } from '@docmost/db/types/entity.types'; @@ -51,6 +60,8 @@ function parseQuery(schema: { parse: (v: unknown) => T }, raw: unknown): T { * * All mutation endpoints use the same guards and service as the native path. */ +@ApiTags('notifications') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/notifications') export class AcadeniceNotificationsController { @@ -60,6 +71,12 @@ export class AcadeniceNotificationsController { * GET /api/v1/notifications * Paginated list of notifications for the authenticated user. */ + @ApiOperation({ summary: 'List notifications', description: 'Returns paginated notifications for the authenticated user.' }) + @ApiQuery({ name: 'limit', required: false, type: 'number', description: 'Page size' }) + @ApiQuery({ name: 'cursor', required: false, type: 'string', description: 'Cursor for pagination' }) + @ApiQuery({ name: 'tab', required: false, type: 'string', description: 'Filter tab (all, mentions, etc.)' }) + @ApiResponse({ status: 200, description: 'Paginated notification list' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Get() async list( @AuthUser() user: User, @@ -84,6 +101,9 @@ export class AcadeniceNotificationsController { /** * GET /api/v1/notifications/unread-count */ + @ApiOperation({ summary: 'Get unread notification count', description: 'Returns the count of unread notifications for the authenticated user.' }) + @ApiResponse({ status: 200, description: '{ count: number }' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Get('unread-count') async unreadCount(@AuthUser() user: User) { return this.notificationService.getUnreadCount(user.id); @@ -92,6 +112,9 @@ export class AcadeniceNotificationsController { /** * POST /api/v1/notifications/read-all */ + @ApiOperation({ summary: 'Mark all notifications as read', description: 'Marks all notifications for the authenticated user as read.' }) + @ApiResponse({ status: 204, description: 'All notifications marked as read' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Post('read-all') @HttpCode(HttpStatus.NO_CONTENT) async readAll(@AuthUser() user: User): Promise { @@ -101,6 +124,11 @@ export class AcadeniceNotificationsController { /** * POST /api/v1/notifications/mark-read */ + @ApiOperation({ summary: 'Mark specific notifications as read', description: 'Marks an array of notification IDs as read.' }) + @ApiBody({ schema: { type: 'object', required: ['notificationIds'], properties: { notificationIds: { type: 'array', items: { type: 'string', format: 'uuid' } } } }, description: 'IDs to mark as read' }) + @ApiResponse({ status: 204, description: 'Notifications marked as read' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @Post('mark-read') @HttpCode(HttpStatus.NO_CONTENT) async markRead( @@ -117,6 +145,11 @@ export class AcadeniceNotificationsController { /** * POST /api/v1/notifications/:id/read */ + @ApiOperation({ summary: 'Mark a single notification as read', description: 'Marks the specified notification as read.' }) + @ApiParam({ name: 'id', description: 'Notification UUID', type: 'string' }) + @ApiResponse({ status: 204, description: 'Notification marked as read' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Notification not found' }) @Post(':id/read') @HttpCode(HttpStatus.NO_CONTENT) async markOne( diff --git a/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts b/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts index 0aed9391..54cd31c2 100644 --- a/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts +++ b/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts @@ -5,6 +5,12 @@ import { HttpStatus, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -14,11 +20,15 @@ import { AcadeniceRoleService } from '../services/role.service'; const ADMIN_WILDCARD_KEY = 'admin:*'; +@ApiTags('rbac') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/permissions') export class AcadenicePermissionsController { constructor(private readonly roleService: AcadeniceRoleService) {} + @ApiOperation({ summary: 'List all permission keys', description: 'Returns the full permissions catalog — all valid permission strings organized by group.' }) + @ApiResponse({ status: 200, description: 'Array of { key, group, description }' }) @HttpCode(HttpStatus.OK) @Get() list() { @@ -42,6 +52,9 @@ export class AcadenicePermissionsController { * holds `admin:*`, the array is short-circuited to `["admin:*"]` and we * surface the wildcard flag separately for cheap UI checks. */ + @ApiOperation({ summary: 'Get my effective permissions', description: 'Returns the effective permission set for the authenticated user in the current workspace. This is UX scaffolding — backend guards enforce independently.' }) + @ApiResponse({ status: 200, description: '{ userId, workspaceId, permissions, is_admin_wildcard }' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @HttpCode(HttpStatus.OK) @Get('me') async getMyPermissions( diff --git a/apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts b/apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts index f578ad8e..80bbb7d6 100644 --- a/apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts +++ b/apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts @@ -12,6 +12,14 @@ import { Put, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -25,17 +33,28 @@ import { import { AcadenicePermissionsGuard } from '../guards/permissions.guard'; import { RequirePermission } from '../guards/require-permission.decorator'; +@ApiTags('rbac') +@ApiBearerAuth() @UseGuards(JwtAuthGuard, AcadenicePermissionsGuard) @Controller('v1/roles') export class AcadeniceRolesController { constructor(private readonly roleService: AcadeniceRoleService) {} + @ApiOperation({ summary: 'List roles', description: 'Returns all roles defined in the workspace.' }) + @ApiResponse({ status: 200, description: 'Array of role objects' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) @HttpCode(HttpStatus.OK) @Get() async list(@AuthWorkspace() workspace: Workspace) { return this.roleService.listRoles(workspace.id); } + @ApiOperation({ summary: 'Create role', description: 'Creates a new custom workspace role. Requires roles:manage permission.' }) + @ApiBody({ schema: { type: 'object', required: ['name'], properties: { name: { type: 'string', maxLength: 120 }, description: { type: 'string', maxLength: 2000 }, permissions: { type: 'array', items: { type: 'string' } } } }, description: 'Role definition' }) + @ApiResponse({ status: 201, description: 'Role created' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing roles:manage permission' }) @RequirePermission('roles:manage') @HttpCode(HttpStatus.CREATED) @Post() @@ -51,6 +70,12 @@ export class AcadeniceRolesController { }); } + @ApiOperation({ summary: 'Get role by ID', description: 'Returns a single role with its permissions. Requires roles:manage.' }) + @ApiParam({ name: 'id', description: 'Role UUID', type: 'string' }) + @ApiResponse({ status: 200, description: 'Role detail with permissions' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing roles:manage permission' }) + @ApiResponse({ status: 404, description: 'Role not found' }) @RequirePermission('roles:manage') @HttpCode(HttpStatus.OK) @Get(':id') @@ -61,6 +86,13 @@ export class AcadeniceRolesController { return this.roleService.getRoleWithPermissions(id, workspace.id); } + @ApiOperation({ summary: 'Update role metadata', description: 'Updates name/description of a custom role. Requires roles:manage.' }) + @ApiParam({ name: 'id', description: 'Role UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', properties: { name: { type: 'string' }, description: { type: 'string' } } }, description: 'Partial role update' }) + @ApiResponse({ status: 200, description: 'Updated role' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing roles:manage permission' }) + @ApiResponse({ status: 404, description: 'Role not found' }) @RequirePermission('roles:manage') @HttpCode(HttpStatus.OK) @Patch(':id') @@ -75,6 +107,12 @@ export class AcadeniceRolesController { }); } + @ApiOperation({ summary: 'Delete role', description: 'Deletes a custom role. System roles cannot be deleted. Requires roles:manage.' }) + @ApiParam({ name: 'id', description: 'Role UUID', type: 'string' }) + @ApiResponse({ status: 204, description: 'Role deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing roles:manage or attempt to delete system role' }) + @ApiResponse({ status: 404, description: 'Role not found' }) @RequirePermission('roles:manage') @HttpCode(HttpStatus.NO_CONTENT) @Delete(':id') @@ -85,6 +123,11 @@ export class AcadeniceRolesController { await this.roleService.deleteRole(id, workspace.id); } + @ApiOperation({ summary: 'List role permissions', description: 'Returns the permission strings assigned to a role. Requires roles:manage.' }) + @ApiParam({ name: 'id', description: 'Role UUID', type: 'string' }) + @ApiResponse({ status: 200, description: '{ roleId, permissions }' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing roles:manage permission' }) @RequirePermission('roles:manage') @HttpCode(HttpStatus.OK) @Get(':id/permissions') @@ -99,6 +142,12 @@ export class AcadeniceRolesController { return { roleId: role.id, permissions: role.permissions }; } + @ApiOperation({ summary: 'Set role permissions', description: 'Replaces the full permission set of a role. Requires roles:manage.' }) + @ApiParam({ name: 'id', description: 'Role UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', required: ['permissions'], properties: { permissions: { type: 'array', items: { type: 'string' } } } }, description: 'Full permissions replacement' }) + @ApiResponse({ status: 200, description: 'Updated permissions' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing roles:manage permission' }) @RequirePermission('roles:manage') @HttpCode(HttpStatus.OK) @Put(':id/permissions') diff --git a/apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts b/apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts index 5a0c2422..6a0efe0f 100644 --- a/apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts +++ b/apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts @@ -11,6 +11,14 @@ import { Post, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; @@ -33,11 +41,18 @@ import { permissionMatches } from '../permissions-catalog'; * The guard is intentionally hand-rolled here (no `AcadenicePermissionsGuard`) * because the access logic depends on `userId` path param vs the actor. */ +@ApiTags('rbac') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/users/:userId/roles') export class AcadeniceUserRolesController { constructor(private readonly roleService: AcadeniceRoleService) {} + @ApiOperation({ summary: 'List user roles', description: 'Returns the roles assigned to the specified user. Actor must be the user themselves OR have roles:manage.' }) + @ApiParam({ name: 'userId', description: 'Target user UUID', type: 'string' }) + @ApiResponse({ status: 200, description: 'Array of role assignments' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Missing roles:manage and not self' }) @HttpCode(HttpStatus.OK) @Get() async list( @@ -51,6 +66,12 @@ export class AcadeniceUserRolesController { return this.roleService.listUserRoles(userId, workspace.id); } + @ApiOperation({ summary: 'Assign roles to user', description: 'Assigns one or more roles to a user. Self-assignment forbidden. Requires roles:manage.' }) + @ApiParam({ name: 'userId', description: 'Target user UUID', type: 'string' }) + @ApiBody({ schema: { type: 'object', required: ['roleIds'], properties: { roleIds: { type: 'array', items: { type: 'string', format: 'uuid' }, minItems: 1 } } }, description: 'Role IDs to assign' }) + @ApiResponse({ status: 200, description: '{ ok: true }' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Self-assignment or missing roles:manage' }) @HttpCode(HttpStatus.OK) @Post() async assign( @@ -74,6 +95,12 @@ export class AcadeniceUserRolesController { return { ok: true }; } + @ApiOperation({ summary: 'Unassign role from user', description: 'Removes a role from a user. Self-unassignment forbidden. Requires roles:manage.' }) + @ApiParam({ name: 'userId', description: 'Target user UUID', type: 'string' }) + @ApiParam({ name: 'roleId', description: 'Role UUID to unassign', type: 'string' }) + @ApiResponse({ status: 204, description: 'Role unassigned' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Self-unassignment or missing roles:manage' }) @HttpCode(HttpStatus.NO_CONTENT) @Delete(':roleId') async unassign( diff --git a/apps/server/src/core/acadenice/security/controllers/oidc-status.controller.ts b/apps/server/src/core/acadenice/security/controllers/oidc-status.controller.ts index 2d8408e4..6bc0db93 100644 --- a/apps/server/src/core/acadenice/security/controllers/oidc-status.controller.ts +++ b/apps/server/src/core/acadenice/security/controllers/oidc-status.controller.ts @@ -4,6 +4,12 @@ import { Get, UseGuards, } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { User } from '@docmost/db/types/entity.types'; @@ -26,11 +32,17 @@ export interface OidcStatusResponse { * Auth : JWT (admin or owner only) * Returns OIDC configuration derived from env vars — no secrets exposed. */ +@ApiTags('security') +@ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('v1/security') export class AcadeniceOidcStatusController { constructor(private readonly env: EnvironmentService) {} + @ApiOperation({ summary: 'Get OIDC configuration status', description: 'Returns OIDC/SSO provider configuration derived from env vars. No secrets exposed. Admin or Owner only.' }) + @ApiResponse({ status: 200, description: 'OIDC status — { enabled, providerName, issuer, scopes, redirectUri, loginUrl }' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Admin or Owner role required' }) @Get('oidc-status') oidcStatus(@AuthUser() user: User): OidcStatusResponse { if ( diff --git a/apps/server/src/core/acadenice/swagger.spec.ts b/apps/server/src/core/acadenice/swagger.spec.ts new file mode 100644 index 00000000..9bb1064a --- /dev/null +++ b/apps/server/src/core/acadenice/swagger.spec.ts @@ -0,0 +1,199 @@ +/** + * Swagger / OpenAPI document metadata tests (R5.3). + * + * These tests verify OpenAPI decorator metadata on the AcadeDoc controllers + * without booting a full NestJS application (avoids ESM module chain issues + * with prosemirror/collaboration utilities in the test environment). + * + * Strategy: + * - Import only the controller class (not its full DI tree) + * - Use `Reflect.getMetadata` to inspect @nestjs/swagger decorator metadata + * - Use `DocumentBuilder` standalone to verify tag/security configuration + */ + +import 'reflect-metadata'; +import { DocumentBuilder } from '@nestjs/swagger'; +import { DECORATORS } from '@nestjs/swagger/dist/constants'; + +// --------------------------------------------------------------------------- +// Minimal stubs so controller imports do not pull the full DI graph +// --------------------------------------------------------------------------- +jest.mock( + '../../../../common/helpers/prosemirror/html/index', + () => ({}), + { virtual: true }, +); +jest.mock( + '../../../../collaboration/collaboration.util', + () => ({}), + { virtual: true }, +); + +// Import only what we need to verify — just the controller classes for metadata +// We avoid importing services/entities to sidestep the prosemirror/collab chain. +// The Swagger decorator metadata is stored on the class constructor itself. + +describe('Swagger OpenAPI configuration (R5.3)', () => { + // --------------------------------------------------------------------------- + // Test 1: DocumentBuilder produces a valid OpenAPI 3 spec skeleton + // --------------------------------------------------------------------------- + it('should build a valid OpenAPI 3 config object', () => { + const config = new DocumentBuilder() + .setTitle('AcadeDoc API') + .setDescription('API officielle AcadeDoc') + .setVersion('1.0') + .addBearerAuth() + .addCookieAuth('authToken') + .addTag('templates', 'Templates de pages') + .addTag('sync-blocks', 'Sync blocks (cross-page content)') + .addTag('audit-log', 'Audit log (read-only)') + .addTag('api-keys', 'Personal API tokens') + .addTag('clipper', 'Web clipper') + .addTag('graph', 'Knowledge graph') + .addTag('rbac', 'RBAC permissions/roles') + .addTag('comments', 'Page + row comments') + .addTag('notifications', 'Notifications + preferences') + .addTag('security', 'OIDC / SSO config') + .addTag('backlinks', 'Bidirectional backlinks') + .addTag('slash-commands', 'Custom slash commands') + .build(); + + expect(config.openapi).toBe('3.0.0'); + expect(config.info.title).toBe('AcadeDoc API'); + expect(config.info.version).toBe('1.0'); + expect(config.tags).toBeDefined(); + }); + + // --------------------------------------------------------------------------- + // Test 2: All expected tags are present + // --------------------------------------------------------------------------- + it('should declare all 12 expected resource tags', () => { + const config = new DocumentBuilder() + .addTag('templates') + .addTag('sync-blocks') + .addTag('audit-log') + .addTag('api-keys') + .addTag('clipper') + .addTag('graph') + .addTag('rbac') + .addTag('comments') + .addTag('notifications') + .addTag('security') + .addTag('backlinks') + .addTag('slash-commands') + .build(); + + const tagNames = (config.tags ?? []).map((t) => t.name); + const expected = [ + 'templates', + 'sync-blocks', + 'audit-log', + 'api-keys', + 'clipper', + 'graph', + 'rbac', + 'comments', + 'notifications', + 'security', + 'backlinks', + 'slash-commands', + ]; + + for (const tag of expected) { + expect(tagNames).toContain(tag); + } + }); + + // --------------------------------------------------------------------------- + // Test 3: BearerAuth security scheme is configured + // --------------------------------------------------------------------------- + it('should include BearerAuth and CookieAuth security schemes', () => { + const config = new DocumentBuilder() + .addBearerAuth() + .addCookieAuth('authToken') + .build(); + + const schemes = config.components?.securitySchemes ?? {}; + const schemeValues = Object.values(schemes) as any[]; + + const hasBearerAuth = schemeValues.some( + (s) => s.type === 'http' && s.scheme === 'bearer', + ); + const hasCookieAuth = schemeValues.some((s) => s.type === 'apiKey' && s.in === 'cookie'); + + expect(hasBearerAuth).toBe(true); + expect(hasCookieAuth).toBe(true); + }); + + // --------------------------------------------------------------------------- + // Test 4: @ApiTags metadata is resolvable on controller classes + // We load the file dynamically in this test only to isolate the import error. + // --------------------------------------------------------------------------- + it('should find @ApiTags on RowCommentsController', async () => { + // RowCommentsController has no service import issues + const { RowCommentsController } = await import( + './comments/controllers/row-comments.controller' + ); + const tags = Reflect.getMetadata( + DECORATORS.API_TAGS, + RowCommentsController, + ); + expect(tags).toContain('comments'); + }); + + // --------------------------------------------------------------------------- + // Test 5: @ApiTags metadata is resolvable on SyncBlocksController + // --------------------------------------------------------------------------- + it('should find @ApiTags on SyncBlocksController', async () => { + const { SyncBlocksController } = await import( + './sync-blocks/controllers/sync-blocks.controller' + ); + const tags = Reflect.getMetadata( + DECORATORS.API_TAGS, + SyncBlocksController, + ); + expect(tags).toContain('sync-blocks'); + }); + + // --------------------------------------------------------------------------- + // Test 6: @ApiTags metadata on AcadenicePermissionsController + // --------------------------------------------------------------------------- + it('should find @ApiTags on AcadenicePermissionsController', async () => { + const { AcadenicePermissionsController } = await import( + './rbac/controllers/permissions.controller' + ); + const tags = Reflect.getMetadata( + DECORATORS.API_TAGS, + AcadenicePermissionsController, + ); + expect(tags).toContain('rbac'); + }); + + // --------------------------------------------------------------------------- + // Test 7: @ApiTags on BacklinksController + // --------------------------------------------------------------------------- + it('should find @ApiTags on BacklinksController', async () => { + const { BacklinksController } = await import( + './backlinks/controllers/backlinks.controller' + ); + const tags = Reflect.getMetadata( + DECORATORS.API_TAGS, + BacklinksController, + ); + expect(tags).toContain('backlinks'); + }); + + // --------------------------------------------------------------------------- + // Test 8: @ApiTags on AcadeniceOidcStatusController + // --------------------------------------------------------------------------- + it('should find @ApiTags on AcadeniceOidcStatusController', async () => { + const { AcadeniceOidcStatusController } = await import( + './security/controllers/oidc-status.controller' + ); + const tags = Reflect.getMetadata( + DECORATORS.API_TAGS, + AcadeniceOidcStatusController, + ); + expect(tags).toContain('security'); + }); +}); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7e82b6d9..0399f218 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -12,6 +12,8 @@ import fastifyMultipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; import fastifyIp from 'fastify-ip'; import { InternalLogFilter } from './common/logger/internal-log-filter'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { cleanupOpenApiDoc } from 'nestjs-zod'; async function bootstrap() { const app = await NestFactory.create( @@ -113,6 +115,38 @@ async function bootstrap() { app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector)); app.enableShutdownHooks(); + // Swagger UI — enabled in dev/staging by default; opt-in for production via env + const swaggerEnabled = + process.env.NODE_ENV !== 'production' || + process.env.SWAGGER_ENABLED === 'true'; + + if (swaggerEnabled) { + const swaggerConfig = new DocumentBuilder() + .setTitle('AcadeDoc API') + .setDescription( + 'API officielle AcadeDoc — endpoints v1 (acadenice fork extensions on Docmost upstream)', + ) + .setVersion('1.0') + .addBearerAuth() + .addCookieAuth('authToken') + .addTag('templates', 'Templates de pages') + .addTag('sync-blocks', 'Sync blocks (cross-page content)') + .addTag('audit-log', 'Audit log (read-only)') + .addTag('api-keys', 'Personal API tokens') + .addTag('clipper', 'Web clipper') + .addTag('graph', 'Knowledge graph') + .addTag('rbac', 'RBAC permissions/roles') + .addTag('comments', 'Page + row comments') + .addTag('notifications', 'Notifications + preferences') + .addTag('security', 'OIDC / SSO config') + .addTag('backlinks', 'Bidirectional backlinks') + .addTag('slash-commands', 'Custom slash commands') + .build(); + + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/docs', app, cleanupOpenApiDoc(document)); + } + const logger = new Logger('NestApplication'); process.on('unhandledRejection', (reason, promise) => { diff --git a/docs/api-docs.md b/docs/api-docs.md new file mode 100644 index 00000000..467f18c8 --- /dev/null +++ b/docs/api-docs.md @@ -0,0 +1,107 @@ +# AcadeDoc API — Developer Documentation + +## Swagger UI + +Available at `/api/docs` when the server is running in dev or staging mode. + +Production: disabled by default. Enable via `SWAGGER_ENABLED=true` env var. + +``` +http://localhost:3000/api/docs # Swagger UI (interactive) +http://localhost:3000/api/docs-json # OpenAPI 3 JSON spec +``` + +Access requires the workspace subdomain (or hostname middleware) to be set up correctly. The Swagger UI itself is public (no auth wall) but all endpoints inside it require a Bearer token. + +## Authentication + +All `/api/v1/*` endpoints require a Bearer JWT or `authToken` cookie from a successful login. + +To authenticate in Swagger UI: +1. Open `/api/docs` +2. Click "Authorize" (top right) +3. Paste your JWT in the `BearerAuth` field +4. Click "Authorize" and close + +To get a JWT via curl: + +```bash +curl -X POST https://your-workspace.acadedoc.io/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "you@example.com", "password": "..."}' +``` + +Use the `token` field from the response. + +## Example: Bearer authenticated request + +```bash +export TOKEN="eyJ..." +export BASE="https://your-workspace.acadedoc.io" + +curl -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/templates" +``` + +## Exporting the OpenAPI spec + +```bash +curl -s http://localhost:3000/api/docs-json -o openapi.json +``` + +## Generating a TypeScript SDK client + +Using `openapi-generator-cli` (requires Java or Docker): + +```bash +# Install the CLI globally +npm install -g @openapitools/openapi-generator-cli + +# Generate TypeScript-Fetch client +openapi-generator-cli generate \ + -i openapi.json \ + -g typescript-fetch \ + -o ./sdk/acadedoc-client \ + --additional-properties=typescriptThreePlus=true,npmName=acadedoc-client,npmVersion=1.0.0 + +# Or using npx (no global install) +npx @openapitools/openapi-generator-cli generate \ + -i http://localhost:3000/api/docs-json \ + -g typescript-fetch \ + -o ./sdk/acadedoc-client +``` + +Supported generators: `typescript-fetch`, `typescript-axios`, `typescript-node`. + +## Generating a Python SDK + +```bash +openapi-generator-cli generate \ + -i openapi.json \ + -g python \ + -o ./sdk/acadedoc-python \ + --additional-properties=packageName=acadedoc_client +``` + +## Tags reference + +| Tag | Controller prefix | Description | +|-----|------------------|-------------| +| `templates` | `/api/v1/templates` | Page templates CRUD + instantiate | +| `sync-blocks` | `/api/v1/sync-blocks` | Cross-page shared content blocks | +| `audit-log` | `/api/v1/audit-log` | Read-only workspace audit trail (admin) | +| `api-keys` | `/api/v1/api-keys` | Personal access tokens | +| `clipper` | `/api/v1/clipper` | Web clipper import + token management | +| `graph` | `/api/v1/graph` | Knowledge graph (nodes + edges) | +| `rbac` | `/api/v1/permissions`, `/api/v1/roles`, `/api/v1/users/:id/roles` | Role-based access control | +| `comments` | `/api/v1/page-comments`, `/api/v1/row-comments` | Page and Baserow row comments | +| `notifications` | `/api/v1/notifications`, `/api/v1/notification-preferences` | Notifications + preferences | +| `security` | `/api/v1/security` | OIDC/SSO configuration status | +| `backlinks` | `/api/v1/pages/:pageId/backlinks` | Bidirectional page backlinks | +| `slash-commands` | `/api/v1/slash-commands` | Custom editor slash commands | + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SWAGGER_ENABLED` | unset | Set to `true` to enable Swagger UI in production | +| `NODE_ENV` | `development` | Swagger auto-enabled when not `production` | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5b86b09..72085d23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -624,6 +624,9 @@ importers: '@nestjs/schedule': specifier: ^6.1.3 version: 6.1.3(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/swagger': + specifier: ^11.4.2 + version: 11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) '@nestjs/terminus': specifier: ^11.1.1 version: 11.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -726,6 +729,9 @@ importers: nestjs-pino: specifier: ^4.6.1 version: 4.6.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) + nestjs-zod: + specifier: ^5.3.0 + version: 5.3.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6) nodemailer: specifier: ^8.0.5 version: 8.0.5 @@ -3007,6 +3013,9 @@ packages: '@mermaid-js/parser@1.0.1': resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -3206,6 +3215,23 @@ packages: peerDependencies: typescript: '>=4.8.2' + '@nestjs/swagger@11.4.2': + resolution: {integrity: sha512-aBihEogDMj/bLEcaqhkvyX/ZVWUw/bmnhKzR0zwUoyGJikvZyaq7rOPYl/H7Lxkkr3c90SJxyuv1AX2UT1WKlw==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/terminus@11.1.1': resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} peerDependencies: @@ -4451,6 +4477,9 @@ packages: cpu: [x64] os: [win32] + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -8914,6 +8943,17 @@ packages: pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 rxjs: ^7.1.0 + nestjs-zod@5.3.0: + resolution: {integrity: sha512-QY6imXm9heMOpWigjFHgMWPvc1ZQHeNQ7pdogo9Q5xj5F8HpqZ972vKlVdkaTyzYlOXJP/yVy3wlF1EjubDQPg==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0 + rxjs: ^7.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@nestjs/swagger': + optional: true + node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} @@ -10340,6 +10380,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swagger-ui-dist@5.32.4: + resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -13841,6 +13884,8 @@ snapshots: dependencies: langium: 4.2.1 + '@microsoft/tsdoc@0.16.0': {} + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.13(hono@4.12.14) @@ -14077,6 +14122,22 @@ snapshots: transitivePeerDependencies: - chokidar + '@nestjs/swagger@11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.4 + optionalDependencies: + '@fastify/static': 9.1.3 + class-transformer: 0.5.1 + class-validator: 0.15.1 + '@nestjs/terminus@11.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15265,6 +15326,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true + '@scarf/scarf@1.4.0': {} + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -20476,6 +20539,15 @@ snapshots: pino-http: 11.0.0 rxjs: 7.8.2 + nestjs-zod@5.3.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6): + dependencies: + '@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + deepmerge: 4.3.1 + rxjs: 7.8.2 + zod: 4.3.6 + optionalDependencies: + '@nestjs/swagger': 11.4.2(@fastify/static@9.1.3)(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + node-abort-controller@3.1.1: {} node-addon-api@8.5.0: {} @@ -22240,6 +22312,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swagger-ui-dist@5.32.4: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-observable@4.0.0: {} symbol-tree@3.2.4: {}