T-HUB/apps/api/src/live/stream-output.controller.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

162 lines
10 KiB
TypeScript

import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import type { PoolClient } from "pg";
import { parseId, requireSession } from "../http/validation.js";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { streamOutputTarget } from "@tactical-media-broadcast-hub/core";
import { LiveDestinationRepository } from "./live-destination.repository.js";
import type { OutputSummary, TenantTransaction } from "./live-destination.repository.js";
/** Creates/edits stream outputs (the StreamOutput half of the WP9 boundary). */
@Controller("channels/:channelId/outputs")
@UseGuards(RbacGuard)
export class StreamOutputController {
constructor(
@Inject(LiveDestinationRepository) private readonly destinations: LiveDestinationRepository,
@Inject("STREAM_OUTPUT_TRANSACTION") private readonly transaction: TenantTransaction,
) {}
@Post()
@RequirePermission("streamoutput.manage")
async create(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string, @Body() body: unknown): Promise<OutputSummary> {
const session = requireSession(request);
const parsed = parseId(channelId, "channelId");
const dto = parseCreateOutput(body);
return this.transaction(session.workspaceId, async (client) => {
await this.assertChannel(client, session.workspaceId, parsed);
// Type + API validation of the aggregate boundary: exactly one target.
const target = streamOutputTarget({ destinationId: dto.destinationId, credentialHandleId: dto.credentialHandleId });
if (target === null) throw new BadRequestException("output must reference a destination or its own credential handle");
if (target.kind === "destination") await this.destinations.get(session.workspaceId, target.destinationId);
if (target.kind === "handle" && !/^vh_[A-Za-z0-9_-]{16,128}$/.test(target.credentialHandleId)) {
throw new BadRequestException("credentialHandleId must be a vault handle reference");
}
const result = await client.query(
`INSERT INTO stream_outputs (workspace_id, virtual_channel_id, label, profile, mode, enabled, destination_id, credential_handle_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[session.workspaceId, parsed, dto.label, dto.profile, dto.mode, dto.enabled, dto.destinationId ?? null, dto.credentialHandleId ?? null],
);
return mapOutput(result.rows[0] ?? {});
});
}
@Get()
@RequirePermission("streamoutput.manage")
async list(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string): Promise<OutputSummary[]> {
const session = requireSession(request);
return this.transaction(session.workspaceId, async (client) => {
await this.assertChannel(client, session.workspaceId, parseId(channelId, "channelId"));
const result = await client.query(
"SELECT * FROM stream_outputs WHERE workspace_id = $1 AND virtual_channel_id = $2 ORDER BY created_at",
[session.workspaceId, channelId],
);
return result.rows.map(mapOutput);
});
}
@Patch(":id")
@RequirePermission("streamoutput.manage")
async update(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string, @Param("id") id: string, @Body() body: unknown): Promise<OutputSummary> {
const session = requireSession(request);
const parsedChannel = parseId(channelId, "channelId");
const parsedId = parseId(id);
const dto = parseUpdateOutput(body);
return this.transaction(session.workspaceId, async (client) => {
await this.assertChannel(client, session.workspaceId, parsedChannel);
// Re-check the exclusivity invariant whenever a target field changes.
if (dto.destinationId !== undefined || dto.credentialHandleId !== undefined) {
const current = await client.query<{ destination_id: string | null; credential_handle_id: string | null }>(
"SELECT destination_id, credential_handle_id FROM stream_outputs WHERE workspace_id=$1 AND virtual_channel_id=$2 AND id=$3",
[session.workspaceId, parsedChannel, parsedId],
);
const row = current.rows[0];
if (row === undefined) throw new BadRequestException("stream output not found");
const nextDestination = dto.destinationId === undefined ? row.destination_id ?? undefined : dto.destinationId ?? undefined;
const nextHandle = dto.credentialHandleId === undefined ? row.credential_handle_id ?? undefined : dto.credentialHandleId ?? undefined;
try { streamOutputTarget({ destinationId: nextDestination, credentialHandleId: nextHandle }); } catch {
throw new BadRequestException("output cannot reference both a destination and its own credential handle");
}
if (nextDestination !== undefined) await this.destinations.get(session.workspaceId, nextDestination);
}
const result = await client.query(
`UPDATE stream_outputs SET
label = COALESCE($3, label), enabled = COALESCE($4, enabled),
profile = COALESCE($5, profile), mode = COALESCE($6, mode),
destination_id = CASE WHEN $7 THEN $8 ELSE destination_id END,
credential_handle_id = CASE WHEN $9 THEN $10 ELSE credential_handle_id END,
updated_at = now()
WHERE workspace_id = $1 AND virtual_channel_id = $2 AND id = $11 RETURNING *`,
[session.workspaceId, parsedChannel, dto.label ?? null, dto.enabled ?? null, dto.profile ?? null, dto.mode ?? null,
dto.destinationId !== undefined, dto.destinationId ?? null,
dto.credentialHandleId !== undefined, dto.credentialHandleId ?? null, parsedId],
);
if (result.rows[0] === undefined) throw new BadRequestException("stream output not found");
return mapOutput(result.rows[0]);
});
}
@Delete(":id")
@RequirePermission("streamoutput.manage")
async remove(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string, @Param("id") id: string): Promise<{ id: string }> {
const session = requireSession(request);
const parsedId = parseId(id);
await this.transaction(session.workspaceId, async (client) => {
await this.assertChannel(client, session.workspaceId, parseId(channelId, "channelId"));
await client.query("DELETE FROM stream_outputs WHERE workspace_id=$1 AND virtual_channel_id=$2 AND id=$3",
[session.workspaceId, channelId, parsedId]);
});
return { id: parsedId };
}
private async assertChannel(client: PoolClient, workspaceId: string, channelId: string): Promise<void> {
const found = await client.query("SELECT id FROM virtual_channels WHERE workspace_id=$1 AND id=$2", [workspaceId, channelId]);
if (found.rowCount === 0) throw new BadRequestException("channelId must reference a virtual channel in this workspace");
}
}
export function mapOutput(row: Record<string, unknown>): OutputSummary {
return {
id: String(row.id), workspaceId: String(row.workspace_id), virtualChannelId: String(row.virtual_channel_id),
label: String(row.label), profile: String(row.profile), mode: String(row.mode), enabled: Boolean(row.enabled),
destinationId: row.destination_id === null ? null : String(row.destination_id),
credentialHandleId: row.credential_handle_id === null ? null : String(row.credential_handle_id),
createdAt: new Date(row.created_at as Date), updatedAt: new Date(row.updated_at as Date),
};
}
const LABEL = /^.{1,120}$/s;
export function parseCreateOutput(value: unknown): { label: string; profile: string; mode: string; enabled: boolean; destinationId?: string; credentialHandleId?: string } {
if (typeof value !== "object" || value === null) throw new BadRequestException("body must be an object");
const body = value as Record<string, unknown>;
const label = typeof body.label === "string" ? body.label.trim() : "";
if (!LABEL.test(label)) throw new BadRequestException("label must be 1-120 characters");
const profile = body.profile === undefined ? "720p30" : String(body.profile);
if (!["720p30", "1080p30", "1080p60"].includes(profile)) throw new BadRequestException("profile must be 720p30, 1080p30 or 1080p60");
const mode = body.mode === undefined ? "passthrough" : String(body.mode);
if (!["passthrough", "normalize"].includes(mode)) throw new BadRequestException("mode must be passthrough or normalize");
const enabled = body.enabled === undefined ? true : Boolean(body.enabled);
const dto: { label: string; profile: string; mode: string; enabled: boolean; destinationId?: string; credentialHandleId?: string } = { label, profile, mode, enabled };
if (body.destinationId !== undefined && body.destinationId !== null) dto.destinationId = String(body.destinationId);
if (body.credentialHandleId !== undefined && body.credentialHandleId !== null) dto.credentialHandleId = String(body.credentialHandleId);
return dto;
}
export function parseUpdateOutput(value: unknown): { label?: string; profile?: string; mode?: string; enabled?: boolean; destinationId?: string | null; credentialHandleId?: string | null } {
if (typeof value !== "object" || value === null) throw new BadRequestException("body must be an object");
const body = value as Record<string, unknown>;
const dto: { label?: string; profile?: string; mode?: string; enabled?: boolean; destinationId?: string | null; credentialHandleId?: string | null } = {};
if (body.label !== undefined) {
const label = typeof body.label === "string" ? body.label.trim() : "";
if (!LABEL.test(label)) throw new BadRequestException("label must be 1-120 characters");
dto.label = label;
}
if (body.profile !== undefined && !["720p30", "1080p30", "1080p60"].includes(String(body.profile))) throw new BadRequestException("profile must be 720p30, 1080p30 or 1080p60");
if (body.profile !== undefined) dto.profile = String(body.profile);
if (body.mode !== undefined && !["passthrough", "normalize"].includes(String(body.mode))) throw new BadRequestException("mode must be passthrough or normalize");
if (body.mode !== undefined) dto.mode = String(body.mode);
if (body.enabled !== undefined) dto.enabled = Boolean(body.enabled);
if (body.destinationId !== undefined) dto.destinationId = body.destinationId === null ? null : String(body.destinationId);
if (body.credentialHandleId !== undefined) dto.credentialHandleId = body.credentialHandleId === null ? null : String(body.credentialHandleId);
return dto;
}