T-HUB/apps/api/src/director/director.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

84 lines
5 KiB
TypeScript

/**
* Internal director endpoints (WP10 wiring slice).
*
* Process-to-process only: a shared-secret bearer (DIRECTOR_API_TOKEN) when
* configured, plus the ref's workspace/channel UUID shape. Not registered under
* the session RBAC guard — the scheduler worker has no browser session; its
* authorization is the generation/version fence in DirectorService.
*/
import { BadRequestException, Body, Controller, Get, Headers, HttpCode, Inject, Param, Post } from "@nestjs/common";
import { DirectorService, type DirectorApplyBody } from "./director.service.js";
function requireToken(token: string | undefined): void {
const expected = process.env.DIRECTOR_API_TOKEN;
if (expected === undefined || expected === "") return; // unset = local dev, no shared secret configured
if (token !== `Bearer ${expected}`) throw new BadRequestException("director token is invalid");
}
@Controller("internal/director/channels")
export class DirectorController {
constructor(@Inject(DirectorService) private readonly director: DirectorService) {}
@Get(":workspaceId/:channelId/state")
async state(@Headers() headers: Record<string, string>, @Param("workspaceId") ws: string, @Param("channelId") ch: string): Promise<unknown> {
requireToken(headers.authorization);
return this.director.channelState(ws, ch);
}
@Get(":workspaceId/:channelId/fence")
async fence(@Headers() headers: Record<string, string>, @Param("workspaceId") ws: string, @Param("channelId") ch: string): Promise<unknown> {
requireToken(headers.authorization);
return this.director.fence(ws, ch);
}
@Post(":workspaceId/:channelId/apply")
@HttpCode(200)
async apply(@Headers() headers: Record<string, string>, @Param("workspaceId") ws: string, @Param("channelId") ch: string,
@Body() body: unknown): Promise<unknown> {
requireToken(headers.authorization);
return this.director.applyProgramSwitch(ws, ch, parseApply(body));
}
@Post(":workspaceId/:channelId/audit")
@HttpCode(204)
async audit(@Headers() headers: Record<string, string>, @Param("workspaceId") ws: string, @Param("channelId") ch: string,
@Body() body: unknown): Promise<void> {
requireToken(headers.authorization);
const event = body as { commandId?: unknown; outcome?: unknown; reason?: unknown };
if (typeof event.commandId !== "string" || (event.outcome !== "SUPERSEDED" && event.outcome !== "AUTHZ_REVOKED")
|| typeof event.reason !== "string") throw new BadRequestException("audit event is invalid");
await this.director.audit(ws, ch, { commandId: event.commandId, outcome: event.outcome, reason: event.reason });
}
}
function parseApply(body: unknown): DirectorApplyBody {
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new BadRequestException("apply body must be an object");
const value = body as Record<string, unknown>;
const commandId = value.commandId;
if (typeof commandId !== "string" || commandId === "" || commandId.length > 128) throw new BadRequestException("commandId must be 1-128 characters");
const expectedDesiredVersion = value.expectedDesiredVersion;
if (typeof expectedDesiredVersion !== "number" || !Number.isSafeInteger(expectedDesiredVersion) || expectedDesiredVersion < 0) {
throw new BadRequestException("expectedDesiredVersion must be a non-negative integer");
}
const authzEpoch = value.authzEpoch;
if (typeof authzEpoch !== "number" || !Number.isSafeInteger(authzEpoch) || authzEpoch < 0) {
throw new BadRequestException("authzEpoch must be a non-negative integer");
}
const program = value.program;
if (typeof program !== "object" || program === null) throw new BadRequestException("program must be an object");
const p = program as Record<string, unknown>;
let parsedProgram: DirectorApplyBody["program"];
if (p.kind === "sources") {
if (!Array.isArray(p.sourceIds) || p.sourceIds.some((id) => typeof id !== "string")) throw new BadRequestException("program.sourceIds must be strings");
parsedProgram = { kind: "sources", sourceIds: p.sourceIds };
} else if (p.kind === "fallback") {
if (p.sceneId !== null && typeof p.sceneId !== "string") throw new BadRequestException("program.sceneId must be a string or null");
parsedProgram = { kind: "fallback", sceneId: p.sceneId as string | null };
} else throw new BadRequestException("program.kind must be sources or fallback");
const layout = value.layout;
if (typeof layout !== "string" || layout === "") throw new BadRequestException("layout must be a non-empty string");
if (!Array.isArray(value.reasons) || value.reasons.some((r) => typeof r !== "string")) throw new BadRequestException("reasons must be strings");
const releaseOverride = value.releaseOverride;
if (releaseOverride !== undefined && typeof releaseOverride !== "string") throw new BadRequestException("releaseOverride must be a string");
return { commandId, expectedDesiredVersion, authzEpoch, program: parsedProgram, layout, reasons: value.reasons, ...(releaseOverride === undefined ? {} : { releaseOverride }) };
}