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/.
84 lines
5 KiB
TypeScript
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 }) };
|
|
}
|