Per the owner's spec: each social connection gets publishGovernance in its fields — unset = manual publish (today), auto = dispatch on approval, admin1/admin_majority/admin_consensus = a publish-phase chatgov poll (subject draft_publish, its own open-poll slot, no collision with review gates). Dispatch holds until the publish poll passes (E3 pre-dispatch check); auto dispatches on approval. Includes migration 033, the PATCH /connections/:id/publish-governance route (chatgov.manage), and 19+31 new tests. Implementation by the branding-panel process (claude/opus), lint and integration fixes here.
106 lines
5.9 KiB
TypeScript
106 lines
5.9 KiB
TypeScript
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<ConnectionSummary[]> {
|
|
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<string, unknown>, @Res() response: Response): Promise<void> {
|
|
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;
|
|
}
|