feat(api): REST API with OpenAPI for other apps, behind Pireon OAuth

A REST adapter next to GraphQL (presenters/http) that calls the same
application services, so validation, permissions and domain errors are the
same: projects (members, board, stats, activity), issues (search, workflow,
assignee, release, links, comments, history), comments, releases and users.
Projects and issues can be addressed by id or key (WEB, WEB-12).

- Swagger UI at /api/docs, document at /api/docs-json and apps/api/openapi.json
  (rewritten by `nx serve api`, like schema.gql) for client generation.
- Every route but health/auth/webhook needs a Pireon token: the document
  declares Pireon OAuth2 (authorization code + PKCE, password) through the
  /api/auth/token proxy, and a bearer scheme for apps with their own client.
- configureApp() shares main.ts setup with the e2e, which now also covers REST.
- SWAGGER_ENABLED / OPENAPI_FILE config; docs 03/05 and library READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:26:47 +02:00
parent b2c9be72c3
commit 341241a52f
36 changed files with 7386 additions and 42 deletions

View File

@@ -6,6 +6,15 @@ import {
Post,
UnauthorizedException,
} from '@nestjs/common';
import {
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import {
IdentityService,
InvalidCredentialsError,
@@ -18,10 +27,29 @@ import {
import { Public } from '../auth/auth.decorators';
import { IssueTokenDto } from './dto/issue-token.dto';
export class AuthorizationConfigDto {
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
authorizeUrl!: string;
@ApiProperty({ description: 'Public id of the task-stream OAuth client' })
clientId!: string;
@ApiProperty({ type: [String], example: ['users:read'] })
scopes!: string[];
@ApiProperty({ description: 'Whether email + password sign-in is allowed' })
passwordGrant!: boolean;
}
export class TokenResponseDto {
@ApiProperty() access_token!: string;
@ApiProperty({ type: String, nullable: true }) refresh_token!: string | null;
@ApiProperty({ description: 'Seconds' }) expires_in!: number;
@ApiProperty({ example: 'Bearer' }) token_type!: string;
}
/**
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
* secret must stay server-side, so the web app goes through here.
*/
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly identity: IdentityService) {}
@@ -29,6 +57,10 @@ export class AuthController {
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
@Public()
@Get('config')
@ApiOperation({
summary: 'How to sign in with Pireon (null when not configured)',
})
@ApiOkResponse({ type: AuthorizationConfigDto })
config(): Promise<AuthorizationConfig | null> {
return this.identity.authorizationConfig();
}
@@ -36,6 +68,18 @@ export class AuthController {
@Public()
@Post('token')
@HttpCode(200)
@ApiOperation({
summary:
'Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token)',
description:
'For browsers (Pireon has no CORS) and Swagger UI: adds the client secret ' +
'server-side. Apps with their own Pireon OAuth client can get tokens from ' +
'Pireon directly; any valid Pireon access token is accepted by this API.',
})
@ApiConsumes('application/json', 'application/x-www-form-urlencoded')
@ApiBody({ type: IssueTokenDto })
@ApiOkResponse({ type: TokenResponseDto })
@ApiUnauthorizedResponse({ description: 'Pireon rejected the grant' })
async token(@Body() dto: IssueTokenDto) {
try {
return toResponse(await this.identity.issueToken(toGrant(dto)));