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

@@ -9,12 +9,20 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ApiHeader,
ApiNoContentResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { IdentityService } from '@task-stream/api/identity/application';
import { Request } from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Public } from '../auth/auth.decorators';
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
@ApiTags('Webhooks')
@Controller('webhooks/pireon')
export class PireonWebhookController {
constructor(
@@ -25,6 +33,19 @@ export class PireonWebhookController {
@Public()
@Post()
@HttpCode(204)
@ApiOperation({
summary: 'Pireon events (called by Pireon, not by apps)',
description:
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
'Disabled unless PIREON_WEBHOOK_SECRET is set.',
})
@ApiHeader({
name: 'X-Webhook-Signature',
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)',
required: true,
})
@ApiNoContentResponse({ description: 'Processed' })
@ApiUnauthorizedResponse({ description: 'Missing or invalid signature' })
async receive(
@Req() req: RawBodyRequest<Request>,
@Headers('x-webhook-signature') signature?: string,