import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res, UseGuards } from "@nestjs/common"; import type { Request, Response } from "express"; import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js"; import { ConnectionsService, type ConnectionSummary } from "./connections.service.js"; import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js"; import { CHATGOV_PERMISSIONS } from "../chatgov/policy.js"; import { PUBLISH_GOVERNANCE_MODES, type PublishGovernance } from "../publishing/publish-governance.js"; @Controller("connections") export class ConnectionsController { constructor(@Inject(ConnectionsService) private readonly connections: ConnectionsService) {} @Get() @UseGuards(RbacGuard) @RequirePermission("connection.view") async list(@Req() request: Request & AuthenticatedRequest): Promise { return this.connections.list(requireSession(request).workspaceId); } @Get("providers") @UseGuards(RbacGuard) @RequirePermission("connection.view") providers() { return this.connections.providers(); } private redirectResponse(response: Response, authorization: { redirectUrl: string; state: string; binding: string }) { response.setHeader("Cache-Control", "no-store"); response.cookie(stateCookieName(authorization.state), authorization.binding, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/connections/callback", maxAge: AUTH_TTL_MS, }); return { redirectUrl: authorization.redirectUrl }; } @Post("authorize") @UseGuards(RbacGuard) @RequirePermission("connection.manage") async authorize(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown, @Res({ passthrough: true }) response: Response) { const input = requireObject(body, "authorization"); const authorization = await this.connections.authorize(requireSession(request), requireString(input.provider, "provider"), optionalString(input.instanceUrl, "instanceUrl")); return this.redirectResponse(response, authorization); } /** Deliberately outside RbacGuard: the provider's browser redirect carries no bearer header. */ @Get("callback") async callback(@Req() request: Request, @Query() query: Record, @Res() response: Response): Promise { const state = typeof query.state === "string" ? query.state : ""; const name = validState(state) ? stateCookieName(state) : ""; const cookie = name ? (request.headers.cookie ?? "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1) : undefined; let binding = ""; try { binding = decodeURIComponent(cookie ?? ""); } catch { /* Malformed cookies fail state validation. */ } const location = await this.connections.callback(state, binding, typeof query.code === "string" ? query.code : undefined, typeof query.error === "string" ? query.error : undefined); if (name) response.clearCookie(name, { path: "/connections/callback", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" }); response.setHeader("Cache-Control", "no-store"); response.setHeader("Referrer-Policy", "no-referrer"); response.redirect(303, location); } // Public endpoint spelling is API routing, not provider behavior branching. // eslint-disable-next-line no-restricted-syntax @Post("telegram/bot-token") @UseGuards(RbacGuard) @RequirePermission("connection.manage") async botToken(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<{ connected: true; token: string }> { const input = requireObject(body, "bot token"); const id = optionalString(input.connectionId, "connectionId"); const channelId = optionalString(input.channelId, "channelId"); const token = await this.connections.botToken(requireSession(request), requireString(input.token, "token"), channelId ? { channelId } : {}, id ? parseId(id) : undefined); return { connected: true, token }; } @Post(":connectionId/reconnect") @UseGuards(RbacGuard) @RequirePermission("connection.manage") async reconnect(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Res({ passthrough: true }) response: Response) { return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id))); } /** * Publish governance for one connection: auto | admin1 | admin_majority | * admin_consensus, or null for manual publishing. Deciding who must sign * off a publish is a governance rule, so it needs chatgov.manage. */ @Patch(":connectionId/publish-governance") @UseGuards(RbacGuard) @RequirePermission(CHATGOV_PERMISSIONS.manage) async publishGovernance(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Body() body: unknown) { const input = requireObject(body, "publish governance"); return this.connections.setPublishGovernance(requireSession(request), parseId(id), parsePublishGovernance(input.publishGovernance)); } @Delete(":connectionId") @UseGuards(RbacGuard) @RequirePermission("connection.manage") async remove(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string): Promise<{ removed: true; token: string }> { const token = await this.connections.remove(requireSession(request), parseId(id)); return { removed: true, token }; } } function parsePublishGovernance(value: unknown): PublishGovernance | null { if (value === null) return null; const mode = PUBLISH_GOVERNANCE_MODES.find((candidate) => candidate === value); if (mode === undefined) throw new BadRequestException(`publishGovernance must be null or one of: ${PUBLISH_GOVERNANCE_MODES.join(", ")}`); return mode; }