/** * 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, @Param("workspaceId") ws: string, @Param("channelId") ch: string): Promise { requireToken(headers.authorization); return this.director.channelState(ws, ch); } @Get(":workspaceId/:channelId/fence") async fence(@Headers() headers: Record, @Param("workspaceId") ws: string, @Param("channelId") ch: string): Promise { requireToken(headers.authorization); return this.director.fence(ws, ch); } @Post(":workspaceId/:channelId/apply") @HttpCode(200) async apply(@Headers() headers: Record, @Param("workspaceId") ws: string, @Param("channelId") ch: string, @Body() body: unknown): Promise { requireToken(headers.authorization); return this.director.applyProgramSwitch(ws, ch, parseApply(body)); } @Post(":workspaceId/:channelId/audit") @HttpCode(204) async audit(@Headers() headers: Record, @Param("workspaceId") ws: string, @Param("channelId") ch: string, @Body() body: unknown): Promise { 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; 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; 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 }) }; }