T-HUB/apps/scheduler/src/director/http.client.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

128 lines
6 KiB
TypeScript

/**
* HTTP implementation of the director ports (WP10 wiring slice).
*
* The scheduler worker dials the API's internal director endpoints. Every
* response is validated before it reaches the engine: the API is a different
* process, so a malformed or truncated reply must surface as a transport
* error (retryable) rather than poison the evaluator's memory.
*
* Endpoints (apps/api/src/director/director.controller.ts):
* GET /internal/director/channels/:workspaceId/:channelId/state
* GET /internal/director/channels/:workspaceId/:channelId/fence
* POST /internal/director/channels/:workspaceId/:channelId/apply
* POST /internal/director/channels/:workspaceId/:channelId/audit
*/
import type { ChannelRef, DesiredFence } from "./director.activity.js";
import type { DirectorApplyRequest, DirectorApplyResult, DirectorApi, DirectorStateRead } from "./ports.js";
export interface DirectorHttpClientDependencies {
/** Base URL of the API (default http://127.0.0.1:3000). */
baseUrl?: string;
/** Shared-secret bearer for the internal route group. */
token?: string;
fetch?: typeof fetch;
/** Default 8s; short so an unresponsive API fails the activity fast. */
timeoutMs?: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requiredString(value: unknown, path: string): string {
if (typeof value !== "string" || value === "") throw new Error(`director api: ${path} must be a non-empty string`);
return value;
}
/** Shape-check an apply response without trusting the status code alone. */
export function parseApplyResult(value: unknown): DirectorApplyResult {
if (!isRecord(value)) throw new Error("director api: apply response must be an object");
const kind = value.kind;
if (kind === "conflict") return { kind: "conflict" };
if (kind !== "applied" && kind !== "duplicate") throw new Error("director api: apply response kind is invalid");
const desiredVersion = value.desiredVersion;
if (typeof desiredVersion !== "number" || !Number.isSafeInteger(desiredVersion) || desiredVersion < 0) {
throw new Error("director api: apply response desiredVersion is invalid");
}
return { kind, desiredVersion };
}
/** Shape-check a fence response (fence may legitimately be absent). */
export function parseFence(value: unknown): DesiredFence | undefined {
if (value === null || value === undefined) return undefined;
if (!isRecord(value)) throw new Error("director api: fence response must be an object or null");
const generation = requiredString(value.generation, "fence.generation");
const desiredVersion = value.desiredVersion;
const authzEpoch = value.authzEpoch;
if (typeof desiredVersion !== "number" || !Number.isSafeInteger(desiredVersion) || desiredVersion < 0) {
throw new Error("director api: fence.desiredVersion is invalid");
}
if (typeof authzEpoch !== "number" || !Number.isSafeInteger(authzEpoch) || authzEpoch < 0) {
throw new Error("director api: fence.authzEpoch is invalid");
}
if (typeof value.directorAuthorized !== "boolean") throw new Error("director api: fence.directorAuthorized is invalid");
return { generation, desiredVersion, authzEpoch, directorAuthorized: value.directorAuthorized };
}
export class DirectorHttpClient implements DirectorApi {
private readonly baseUrl: string;
private readonly token: string | undefined;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
constructor(dependencies: DirectorHttpClientDependencies = {}) {
this.baseUrl = (dependencies.baseUrl ?? process.env.DIRECTOR_API_URL ?? "http://127.0.0.1:3000").replace(/\/+$/, "");
this.token = dependencies.token ?? process.env.DIRECTOR_API_TOKEN;
this.fetchImpl = dependencies.fetch ?? fetch;
this.timeoutMs = dependencies.timeoutMs ?? 8_000;
}
private path(ref: ChannelRef, suffix: string): string {
return `${this.baseUrl}/internal/director/channels/${encodeURIComponent(ref.workspaceId)}/${encodeURIComponent(ref.channelId)}/${suffix}`;
}
private async request(path: string, init?: RequestInit): Promise<unknown> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await this.fetchImpl(path, {
...init,
signal: controller.signal,
headers: {
"content-type": "application/json",
...(this.token === undefined ? {} : { authorization: `Bearer ${this.token}` }),
...init?.headers,
},
});
if (!response.ok) {
// Transport/server errors are retryable; the evaluator treats 4xx-shaped
// outcomes as fenced results, which the callers parse explicitly.
throw new Error(`director api: ${response.status} ${response.statusText} for ${path}`);
}
const text = await response.text();
return text === "" ? null : JSON.parse(text) as unknown;
} finally {
clearTimeout(timeout);
}
}
async channelState(ref: ChannelRef): Promise<DirectorStateRead> {
const value = await this.request(this.path(ref, "state"));
if (!isRecord(value)) throw new Error("director api: state response must be an object");
if (value.kind === "superseded") return { kind: "superseded", code: "SUPERSEDED" };
if (value.kind !== "current") throw new Error("director api: state response kind is invalid");
return value as DirectorStateRead;
}
async fence(ref: ChannelRef): Promise<DesiredFence | undefined> {
return parseFence(await this.request(this.path(ref, "fence")));
}
async applyProgramSwitch(ref: ChannelRef, body: DirectorApplyRequest): Promise<DirectorApplyResult> {
return parseApplyResult(await this.request(this.path(ref, "apply"), { method: "POST", body: JSON.stringify(body) }));
}
async audit(ref: ChannelRef, event: { commandId: string; outcome: string; reason: string }): Promise<void> {
await this.request(this.path(ref, "audit"), { method: "POST", body: JSON.stringify(event) });
}
}