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/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,171 @@
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException, Optional } from "@nestjs/common";
import type { PoolClient } from "pg";
import { withTenant } from "../db/client.js";
import { LiveDestinationRepository, type OutputSummary } from "./live-destination.repository.js";
import { createGenericHlsResolver, GENERIC_HLS_PROVIDER, type LiveSourceResolver } from "./live-source-resolver.js";
export type TenantTransaction = <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
/**
* The media-plane target a go-live spawns: everything the relay needs, with
* raw secret material held only inside this service's resolution path.
*/
export interface ResolvedOutputTarget {
output: OutputSummary;
/** Ingest endpoint for the push (destination's, or null for a disabled/generic-missing output). */
endpointUrl: string;
/** True when the credential came through a destination (vs the output's own handle). */
throughDestination: boolean;
/** Vault handle the worker's VaultHandleBroker redeems at relay start. */
credentialHandleId: string | null;
}
/** Resolved program input for the take/start path. */
export interface ResolvedProgramSource {
playbackHlsUrl: string;
live: boolean;
provider: string;
}
export interface GoLiveDependencies {
transaction: TenantTransaction;
/** Provider-keyed resolvers; connectors register their own, generic-hls is built in. */
sourceResolvers: Map<string, LiveSourceResolver>;
}
export const GO_LIVE_DEPENDENCIES = Symbol("GO_LIVE_DEPENDENCIES");
/**
* Go-live wiring (WP6/WP9): take/start resolves StreamOutput ->
* LiveStreamDestination -> credential via the vault (same pattern as the
* publish credential accessor), and resolves the program source through
* connector-level resolvers. Nothing here mutates Redis or signs commands;
* callers feed the result into MediaControlService.setDesired/director.
*/
@Injectable()
export class GoLiveService {
private readonly resolvers: Map<string, LiveSourceResolver>;
private readonly transaction: TenantTransaction;
constructor(
@Inject(LiveDestinationRepository) private readonly destinations: LiveDestinationRepository,
@Optional() @Inject(GO_LIVE_DEPENDENCIES) dependencies?: Partial<GoLiveDependencies>,
) {
const defaults: GoLiveDependencies = {
transaction: withTenant,
sourceResolvers: new Map([[GENERIC_HLS_PROVIDER, createGenericHlsResolver()]]),
};
const merged = { ...defaults, ...dependencies };
this.resolvers = merged.sourceResolvers;
this.transaction = merged.transaction;
}
registerSourceResolver(provider: string, resolver: LiveSourceResolver): void {
this.resolvers.set(provider, resolver);
}
/**
* Resolve every enabled output of a channel into media-plane targets.
* Destination-referenced outputs resolve their credential handle through the
* destination (aggregate boundary); handle outputs pass theirs through
* untouched. The raw stream key never enters this surface — the worker
* redeems the handle at relay start.
*/
async resolveOutputs(workspaceId: string, virtualChannelId: string): Promise<ResolvedOutputTarget[]> {
const outputs = await this.listChannelOutputs(workspaceId, virtualChannelId);
const targets: ResolvedOutputTarget[] = [];
for (const output of outputs) {
if (!output.enabled) continue;
if (output.destinationId !== null) {
const destination = await this.destinations.get(workspaceId, output.destinationId);
if (destination.credentialId === null) {
throw new ConflictException(`destination ${destination.label} has no ingest credential`);
}
if (destination.credentialRevokedAt !== null) {
throw new ConflictException(`destination ${destination.label} ingest credential is revoked`);
}
targets.push({
output,
endpointUrl: destination.endpointUrl,
throughDestination: true,
credentialHandleId: destination.credentialId,
});
continue;
}
const handle = output.credentialHandleId;
if (handle === null) throw new ConflictException(`stream output ${output.id} has neither destination nor credential handle`);
targets.push({ output, endpointUrl: "", throughDestination: false, credentialHandleId: handle });
}
return targets;
}
/**
* Take/start resolution of the program source for a virtual channel source
* row: provider-keyed resolvers (twitch via Helix, generic-hls direct) map
* {channelId | externalUrl} to {playbackHlsUrl, live}.
*/
async resolveProgramSource(workspaceId: string, request: { provider: string; channelId?: string; externalUrl?: string }): Promise<ResolvedProgramSource> {
if (request.provider === "") throw new BadRequestException("provider is required");
const resolver = this.resolvers.get(request.provider);
if (resolver === undefined) throw new NotFoundException(`no live source resolver for provider ${request.provider}`);
const resolution = await resolver({ channelId: request.channelId, externalUrl: request.externalUrl });
if (resolution.playbackHlsUrl === null) {
throw new ConflictException(`live source for ${request.provider} is not live`);
}
return { playbackHlsUrl: resolution.playbackHlsUrl, live: resolution.live, provider: request.provider };
}
/**
* Redeem the destination ingest credential (vault.open with tenant + handle
* AAD) for a destination-referenced output. Called only by the relay spawn
* path; the returned secret never reaches logs, audit, or HTTP responses.
*/
async redeemDestinationCredential(workspaceId: string, destinationId: string): Promise<{ endpointUrl: string; streamKey: string; provider: string }> {
return this.destinations.resolveIngestCredential(workspaceId, destinationId);
}
/**
* Take/start: build a DesiredSpec for the go-live flow from the channel's
* enabled StreamOutputs and a resolved program source. Destination-referenced
* outputs contribute the destination's endpoint + credential handle (resolved
* through the vault at relay start); a single-output channel maps onto the
* v1 spec shape MediaControlService.setDesired expects. Channels with
* multiple enabled outputs are a multi-fan-out concern of the worker relay
* (WP9) and resolve per-output there.
*/
async desiredSpecForGoLive(workspaceId: string, virtualChannelId: string, source: { provider: string; channelId?: string; externalUrl?: string },
options?: { profile?: "720p30" | "1080p30" | "1080p60"; mode?: "passthrough" | "normalize" }): Promise<{
desiredStatus: "RUNNING";
source: { kind: "hls"; url: string };
output: { endpointUrl: string; credentialHandleId: string };
profile: "720p30" | "1080p30" | "1080p60";
mode: "passthrough" | "normalize";
}> {
const [program, outputs] = await Promise.all([
this.resolveProgramSource(workspaceId, source),
this.resolveOutputs(workspaceId, virtualChannelId),
]);
if (outputs.length === 0) throw new ConflictException("no enabled stream outputs for this channel");
const target = outputs[0]!;
return {
desiredStatus: "RUNNING",
source: { kind: "hls", url: program.playbackHlsUrl },
output: { endpointUrl: target.endpointUrl, credentialHandleId: target.credentialHandleId ?? "" },
profile: options?.profile ?? (target.output.profile as "720p30" | "1080p30" | "1080p60"),
mode: options?.mode ?? (target.output.mode as "passthrough" | "normalize"),
};
}
private async listChannelOutputs(workspaceId: string, virtualChannelId: string): Promise<OutputSummary[]> {
return this.transaction(workspaceId, async (client) => {
const result = await client.query("SELECT * FROM stream_outputs WHERE workspace_id = $1 AND virtual_channel_id = $2 ORDER BY created_at", [workspaceId, virtualChannelId]);
return result.rows.map((row) => ({
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), updatedAt: new Date(row.updated_at),
}));
});
}
}

View file

@ -0,0 +1,62 @@
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { parseId, requireSession } from "../http/validation.js";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { LiveDestinationRepository, parseCreateDestination, parseUpdateDestination, type DestinationRecord, type OutputSummary } from "./live-destination.repository.js";
@Controller("live/destinations")
@UseGuards(RbacGuard)
export class LiveDestinationController {
constructor(@Inject(LiveDestinationRepository) private readonly destinations: LiveDestinationRepository) {}
@Post()
@RequirePermission("streamoutput.manage")
async create(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<DestinationRecord> {
const session = requireSession(request);
return this.destinations.create(session.workspaceId, parseCreateDestination(body),
{ userId: session.userId, role: session.role });
}
@Get()
@RequirePermission("streamoutput.manage")
async list(@Req() request: Request & AuthenticatedRequest): Promise<DestinationRecord[]> {
return this.destinations.list(requireSession(request).workspaceId);
}
@Get(":id")
@RequirePermission("streamoutput.manage")
async get(@Req() request: Request & AuthenticatedRequest, @Param("id") id: string): Promise<DestinationRecord> {
return this.destinations.get(requireSession(request).workspaceId, parseId(id));
}
@Patch(":id")
@RequirePermission("streamoutput.manage")
async update(
@Req() request: Request & AuthenticatedRequest,
@Param("id") id: string,
@Body() body: unknown,
): Promise<DestinationRecord> {
const session = requireSession(request);
return this.destinations.update(session.workspaceId, parseId(id), parseUpdateDestination(body),
{ userId: session.userId, role: session.role });
}
@Delete(":id")
@RequirePermission("streamoutput.manage")
async remove(
@Req() request: Request & AuthenticatedRequest,
@Param("id") id: string,
): Promise<{ id: string; disabledOutputs: number; revokedCredential: boolean }> {
const session = requireSession(request);
const parsed = parseId(id);
const result = await this.destinations.remove(session.workspaceId, parsed,
{ userId: session.userId, role: session.role });
return { id: parsed, ...result };
}
@Get(":id/outputs")
@RequirePermission("streamoutput.manage")
async outputs(@Req() request: Request & AuthenticatedRequest, @Param("id") id: string): Promise<OutputSummary[]> {
return this.destinations.outputsForDestination(requireSession(request).workspaceId, parseId(id));
}
}

View file

@ -0,0 +1,171 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { Pool, type PoolClient } from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runMigrations } from "../db/migrate.js";
/**
* Migration 022 (WP6 live destinations + stream outputs) against a live
* PostgreSQL: the exclusive-target CHECK, the FK-lifecycle rule, and FORCE
* RLS tenant isolation on both new tables. Assertions run as sp_app
* (DATABASE_URL); setup/cleanup as owner. Skipped without DATABASE_URL.
*/
const dbAvailable = process.env.DATABASE_URL !== undefined;
const ownerPool = new Pool({ connectionString: process.env.SUPER_DATABASE_URL ?? process.env.DATABASE_URL });
const appPool = new Pool({ connectionString: process.env.DATABASE_URL });
const FK_VIOLATION = "23503";
const CHECK_VIOLATION = "23514";
const RLS_VIOLATION = "42501";
async function asTenant<T>(workspaceId: string | null, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await appPool.connect();
try {
await client.query("BEGIN");
if (workspaceId !== null) await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
}
/** Each expected failure runs in its own savepoint so the surrounding tx survives. */
async function expectQueryError(client: PoolClient, codes: string | string[], sql: string, params: unknown[]): Promise<void> {
const accepted = Array.isArray(codes) ? codes : [codes];
await client.query("SAVEPOINT probe");
try {
await client.query(sql, params);
expect.fail(`expected ${sql} to fail with ${accepted.join("|")}`);
} catch (error) {
expect(accepted).toContain((error as { code?: string }).code);
} finally {
await client.query("ROLLBACK TO SAVEPOINT probe");
}
}
describe.skipIf(!dbAvailable)("live destinations + stream outputs (live DB)", () => {
let wsA: string;
let wsB: string;
let channelA: string;
beforeAll(async () => {
await runMigrations(join(process.cwd(), "migrations"));
await ownerPool.query("BEGIN");
const a = await ownerPool.query("INSERT INTO workspaces(name,slug) VALUES('Live A',$1) RETURNING id", [`la-${randomUUID().slice(0, 8)}`]);
const b = await ownerPool.query("INSERT INTO workspaces(name,slug) VALUES('Live B',$1) RETURNING id", [`lb-${randomUUID().slice(0, 8)}`]);
wsA = a.rows[0].id as string;
wsB = b.rows[0].id as string;
await ownerPool.query("SELECT set_config('app.workspace_id', $1, false)", [wsA]);
const ch = await ownerPool.query("INSERT INTO virtual_channels(workspace_id,slug,name) VALUES($1,'live','Live') RETURNING id", [wsA]);
channelA = ch.rows[0].id as string;
await ownerPool.query("COMMIT");
});
afterAll(async () => {
await ownerPool.query("DELETE FROM workspaces WHERE id IN ($1,$2)", [wsA, wsB]);
await appPool.end();
await ownerPool.end();
});
async function destination(client: PoolClient, workspaceId: string, credentialId: string | null = null): Promise<string> {
const result = await client.query(
"INSERT INTO live_stream_destinations(workspace_id,label,provider,endpoint_url,credential_id) VALUES($1,$2,'peertube','rtmps://live.example.net/app',$3) RETURNING id",
[workspaceId, `dest-${randomUUID().slice(0, 8)}`, credentialId],
);
return result.rows[0].id as string;
}
async function credential(client: PoolClient, workspaceId: string): Promise<string> {
const result = await client.query(
"INSERT INTO credentials(workspace_id,kind,ciphertext,key_version) VALUES($1,'ingest-stream-key',gen_random_bytes(48),1) RETURNING id",
[workspaceId],
);
return result.rows[0].id as string;
}
it("holds the exclusive-target CHECK: neither or both targets rejected", async () => {
await asTenant(wsA, async (client) => {
const dest = await destination(client, wsA);
await expectQueryError(client, CHECK_VIOLATION,
"INSERT INTO stream_outputs(workspace_id,virtual_channel_id,label,destination_id,credential_handle_id) VALUES($1,$2,'none',NULL,NULL)",
[wsA, channelA]);
await expectQueryError(client, CHECK_VIOLATION,
"INSERT INTO stream_outputs(workspace_id,virtual_channel_id,label,destination_id,credential_handle_id) VALUES($1,$2,'both',$3,'vh_0123456789abcdef')",
[wsA, channelA, dest]);
await client.query(
"INSERT INTO stream_outputs(workspace_id,virtual_channel_id,label,destination_id,credential_handle_id) VALUES($1,$2,'dest',$3,NULL)",
[wsA, channelA, dest],
);
});
});
it("FK-lifecycle: deleting the destination while outputs reference it fails", async () => {
await asTenant(wsA, async (client) => {
const dest = await destination(client, wsA);
await client.query(
"INSERT INTO stream_outputs(workspace_id,virtual_channel_id,label,destination_id,credential_handle_id,enabled) VALUES($1,$2,'x',$3,NULL,false)",
[wsA, channelA, dest],
);
await expect(client.query("DELETE FROM live_stream_destinations WHERE workspace_id=$1 AND id=$2", [wsA, dest]))
.rejects.toMatchObject({ code: FK_VIOLATION });
});
});
it("FK-lifecycle: the credential cannot be deleted before its destination", async () => {
await asTenant(wsA, async (client) => {
const cred = await credential(client, wsA);
const dest = await destination(client, wsA, cred);
await expectQueryError(client, FK_VIOLATION,
"DELETE FROM credentials WHERE workspace_id=$1 AND id=$2", [wsA, cred]);
// Orderly lifecycle: delete destination first, then the credential row.
await client.query("DELETE FROM live_stream_destinations WHERE workspace_id=$1 AND id=$2", [wsA, dest]);
await client.query("DELETE FROM credentials WHERE workspace_id=$1 AND id=$2", [wsA, cred]);
});
});
it("FORCE RLS isolates both new tables across tenants", async () => {
const destA = await asTenant(wsA, async (client) => destination(client, wsA));
await asTenant(wsB, async (client) => {
const rows = await client.query("SELECT count(*)::int AS n FROM live_stream_destinations");
expect(rows.rows[0].n).toBe(0);
await expectQueryError(client, RLS_VIOLATION,
"INSERT INTO live_stream_destinations(workspace_id,label,provider,endpoint_url) VALUES($1,'cross','peertube','rtmps://live.example.net/app')",
[wsA]);
// A stream output of B referencing A's destination is denied — by the
// tenant policy and/or the tenant-composite FK, whichever fires first.
const channelB = await client.query(
"INSERT INTO virtual_channels(workspace_id,slug,name) VALUES($1,'live','Live') RETURNING id", [wsB]);
await expectQueryError(client, [RLS_VIOLATION, FK_VIOLATION],
"INSERT INTO stream_outputs(workspace_id,virtual_channel_id,label,destination_id,credential_handle_id) VALUES($1,$2,'cross',$3,NULL)",
[wsB, channelB.rows[0].id, destA]);
// And the denial is durable: B still has no outputs at all.
const bOutputs = await client.query("SELECT count(*)::int AS n FROM stream_outputs");
expect(bOutputs.rows[0].n).toBe(0);
});
const seen = await asTenant(wsA, async (client) => client.query("SELECT count(*)::int AS n FROM live_stream_destinations"));
expect(seen.rows[0].n).toBeGreaterThanOrEqual(1);
// No tenant context at all: zero rows, fail closed (FORCE binds sp_app).
const none = await asTenant(null, async (client) => client.query("SELECT count(*)::int AS n FROM live_stream_destinations"));
expect(none.rows[0].n).toBe(0);
});
it("rejects workspace_id changes on both tables (lifecycle immutability)", async () => {
await asTenant(wsA, async (client) => {
const dest = await destination(client, wsA);
await expectQueryError(client, CHECK_VIOLATION,
"UPDATE live_stream_destinations SET workspace_id=$2 WHERE id=$1", [dest, wsB]);
const out = (await client.query(
"INSERT INTO stream_outputs(workspace_id,virtual_channel_id,label,credential_handle_id) VALUES($1,$2,'g','vh_0123456789abcdef') RETURNING id",
[wsA, channelA],
)).rows[0].id as string;
await expectQueryError(client, CHECK_VIOLATION,
"UPDATE stream_outputs SET workspace_id=$2 WHERE id=$1", [out, wsB]);
});
});
});

View file

@ -0,0 +1,348 @@
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException, Optional } from "@nestjs/common";
import type { PoolClient } from "pg";
import { Vault } from "@tactical-media-broadcast-hub/connector-runtime";
import { vault } from "../vault/vault.js";
import { withTenant } from "../db/client.js";
import type { LiveStreamDestination } from "@tactical-media-broadcast-hub/core";
import { validateEndpointShape } from "../media-control/endpoints.js";
import { scrub } from "../audit/audit.service.js";
export type DestinationPlatformState = LiveStreamDestination["lastPlatformState"];
export interface DestinationRecord {
id: string;
workspaceId: string;
label: string;
provider: string;
endpointUrl: string;
credentialId: string | null;
credentialRevokedAt: Date | null;
lastPlatformState: DestinationPlatformState;
createdAt: Date;
updatedAt: Date;
}
export interface CreateDestination {
label: string;
provider: string;
endpointUrl: string;
credentialId?: string | null;
}
export interface UpdateDestination extends Partial<CreateDestination> {
lastPlatformState?: DestinationPlatformState;
}
export interface OutputSummary {
id: string;
workspaceId: string;
virtualChannelId: string;
label: string;
profile: string;
mode: string;
enabled: boolean;
destinationId: string | null;
credentialHandleId: string | null;
createdAt: Date;
updatedAt: Date;
}
/** Credential material the go-live path resolves for a destination-referenced output. */
export interface ResolvedIngestCredential {
endpointUrl: string;
/** Raw secret, only ever returned to the vault-backed relay path — never logged or audited. */
streamKey: string;
provider: string;
}
export type TenantTransaction = <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
export interface LiveDestinationDependencies {
transaction: TenantTransaction;
encryption: Vault;
}
export const LIVE_DESTINATION_DEPENDENCIES = Symbol("LIVE_DESTINATION_DEPENDENCIES");
const DEFAULT_DEPENDENCIES: LiveDestinationDependencies = { transaction: withTenant, encryption: vault };
const LABEL = /^.{1,120}$/s;
const PROVIDER = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const STATE_VALUES: readonly DestinationPlatformState[] = ["unknown", "created", "waiting", "live", "ended"];
function destinationFromRow(row: Record<string, unknown>): DestinationRecord {
return {
id: String(row.id),
workspaceId: String(row.workspace_id),
label: String(row.label),
provider: String(row.provider),
endpointUrl: String(row.endpoint_url),
credentialId: row.credential_id === null ? null : String(row.credential_id),
credentialRevokedAt: row.credential_revoked_at === null || row.credential_revoked_at === undefined ? null : new Date(row.credential_revoked_at as string),
lastPlatformState: (row.last_platform_state ?? "unknown") as DestinationPlatformState,
createdAt: new Date(row.created_at as Date),
updatedAt: new Date(row.updated_at as Date),
};
}
function outputFromRow(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),
};
}
export function parseCreateDestination(value: unknown): CreateDestination {
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 provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
if (!PROVIDER.test(provider)) throw new BadRequestException("provider must be 1-64 lowercase identifier characters");
const endpointUrl = typeof body.endpointUrl === "string" ? body.endpointUrl : "";
if (endpointUrl === "") throw new BadRequestException("endpointUrl is required");
try { validateEndpointShape(endpointUrl, "output"); } catch (error: unknown) {
throw new BadRequestException(`endpointUrl: ${error instanceof Error ? error.message : "invalid endpoint"}`);
}
const credentialId = body.credentialId === undefined || body.credentialId === null ? null : String(body.credentialId);
return { label, provider, endpointUrl, credentialId };
}
export function parseUpdateDestination(value: unknown): UpdateDestination {
if (typeof value !== "object" || value === null) throw new BadRequestException("body must be an object");
const body = value as Record<string, unknown>;
const update: UpdateDestination = {};
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");
update.label = label;
}
if (body.provider !== undefined) {
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
if (!PROVIDER.test(provider)) throw new BadRequestException("provider must be 1-64 lowercase identifier characters");
update.provider = provider;
}
if (body.endpointUrl !== undefined) {
const endpointUrl = typeof body.endpointUrl === "string" ? body.endpointUrl : "";
if (endpointUrl === "") throw new BadRequestException("endpointUrl cannot be empty");
try { validateEndpointShape(endpointUrl, "output"); } catch (error: unknown) {
throw new BadRequestException(`endpointUrl: ${error instanceof Error ? error.message : "invalid endpoint"}`);
}
update.endpointUrl = endpointUrl;
}
if (body.credentialId !== undefined) update.credentialId = body.credentialId === null ? null : String(body.credentialId);
if (body.lastPlatformState !== undefined) {
if (!STATE_VALUES.includes(body.lastPlatformState as DestinationPlatformState)) throw new BadRequestException("lastPlatformState is invalid");
update.lastPlatformState = body.lastPlatformState as DestinationPlatformState;
}
if (Object.keys(update).length === 0) throw new BadRequestException("no recognized fields to update");
return update;
}
@Injectable()
export class LiveDestinationRepository {
private readonly deps: LiveDestinationDependencies;
constructor(@Optional() @Inject(LIVE_DESTINATION_DEPENDENCIES) dependencies?: Partial<LiveDestinationDependencies>) {
this.deps = { ...DEFAULT_DEPENDENCIES, ...dependencies };
}
async list(workspaceId: string): Promise<DestinationRecord[]> {
return this.deps.transaction(workspaceId, async (client) => {
const result = await client.query(
`SELECT d.*, c.revoked_at AS credential_revoked_at
FROM live_stream_destinations d
LEFT JOIN credentials c ON c.id = d.credential_id AND c.workspace_id = d.workspace_id
WHERE d.workspace_id = $1 ORDER BY d.label, d.id`,
[workspaceId],
);
return result.rows.map(destinationFromRow);
});
}
async get(workspaceId: string, id: string): Promise<DestinationRecord> {
return this.deps.transaction(workspaceId, async (client) => this.read(client, workspaceId, id));
}
private async read(client: PoolClient, workspaceId: string, id: string): Promise<DestinationRecord> {
const result = await client.query(
`SELECT d.*, c.revoked_at AS credential_revoked_at
FROM live_stream_destinations d
LEFT JOIN credentials c ON c.id = d.credential_id AND c.workspace_id = d.workspace_id
WHERE d.workspace_id = $1 AND d.id = $2`,
[workspaceId, id],
);
const row = result.rows[0];
if (row === undefined) throw new NotFoundException("live stream destination not found");
return destinationFromRow(row);
}
async create(workspaceId: string, input: CreateDestination, actor: { userId: string | null; role: string }): Promise<DestinationRecord> {
return this.deps.transaction(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
if (input.credentialId !== null) await this.assertLiveCredential(client, workspaceId, input.credentialId ?? "");
const result = await client.query(
`INSERT INTO live_stream_destinations (workspace_id, label, provider, endpoint_url, credential_id)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[workspaceId, input.label, input.provider, input.endpointUrl, input.credentialId],
);
const record = destinationFromRow(result.rows[0] ?? {});
await this.audit(client, workspaceId, actor, "live.destination.created", record.id, {
provider: record.provider, labeled: record.label,
});
return record;
});
}
async update(workspaceId: string, id: string, input: UpdateDestination, actor: { userId: string | null; role: string }): Promise<DestinationRecord> {
return this.deps.transaction(workspaceId, async (client) => {
await client.query("SELECT id FROM live_stream_destinations WHERE workspace_id = $1 AND id = $2 FOR UPDATE", [workspaceId, id]);
const before = await this.read(client, workspaceId, id);
if (input.credentialId !== undefined && input.credentialId !== null) {
await this.assertLiveCredential(client, workspaceId, input.credentialId);
}
const result = await client.query(
`UPDATE live_stream_destinations SET
label = COALESCE($3, label), provider = COALESCE($4, provider),
endpoint_url = COALESCE($5, endpoint_url),
credential_id = CASE WHEN $6 THEN $7 ELSE credential_id END,
last_platform_state = COALESCE($8, last_platform_state),
updated_at = now()
WHERE workspace_id = $1 AND id = $2 RETURNING *`,
[workspaceId, id, input.label ?? null, input.provider ?? null, input.endpointUrl ?? null,
input.credentialId !== undefined, input.credentialId ?? null, input.lastPlatformState ?? null],
);
if (result.rows[0] === undefined) throw new NotFoundException("live stream destination not found");
const record = destinationFromRow(result.rows[0]);
await this.audit(client, workspaceId, actor, "live.destination.updated", id, {
providerChanged: input.provider !== undefined && input.provider !== before.provider,
endpointChanged: input.endpointUrl !== undefined && input.endpointUrl !== before.endpointUrl,
credentialChanged: input.credentialId !== undefined,
});
return record;
});
}
/**
* WP6 lifecycle rule: 409 while a referencing output is enabled; otherwise
* disable dependents, revoke the ingest credential, delete the row.
*/
async remove(workspaceId: string, id: string, actor: { userId: string | null; role: string }): Promise<{ disabledOutputs: number; revokedCredential: boolean }> {
return this.deps.transaction(workspaceId, async (client) => {
await client.query("SELECT id FROM live_stream_destinations WHERE workspace_id = $1 AND id = $2 FOR UPDATE", [workspaceId, id]);
const record = await this.read(client, workspaceId, id);
const referencing = await client.query<{ id: string; enabled: boolean }>(
"SELECT id, enabled FROM stream_outputs WHERE workspace_id = $1 AND destination_id = $2",
[workspaceId, id],
);
const enabled = referencing.rows.filter((row) => row.enabled);
if (enabled.length > 0) {
throw new ConflictException(
`live stream destination is referenced by ${enabled.length} enabled stream output(s); disable them first`,
);
}
const disabled = await client.query(
"UPDATE stream_outputs SET enabled = false, updated_at = now() WHERE workspace_id = $1 AND destination_id = $2 AND enabled RETURNING id",
[workspaceId, id],
);
const deleted = await client.query("DELETE FROM live_stream_destinations WHERE workspace_id = $1 AND id = $2 RETURNING id", [workspaceId, id]);
if (deleted.rows[0] === undefined) throw new NotFoundException("live stream destination not found");
let revoked = false;
if (record.credentialId !== null) {
await client.query("UPDATE credentials SET revoked_at = now() WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL", [record.credentialId, workspaceId]);
revoked = true;
}
await this.audit(client, workspaceId, actor, "live.destination.deleted", id, {
disabledOutputs: disabled.rows.length, revokedCredential: revoked,
});
return { disabledOutputs: disabled.rows.length, revokedCredential: revoked };
});
}
async outputsForDestination(workspaceId: string, destinationId: string): Promise<OutputSummary[]> {
return this.deps.transaction(workspaceId, async (client) => {
const result = await client.query(
"SELECT * FROM stream_outputs WHERE workspace_id = $1 AND destination_id = $2 ORDER BY created_at",
[workspaceId, destinationId],
);
return result.rows.map(outputFromRow);
});
}
/**
* Credential revoke disables dependents (WP9 lifecycle): a revoked ingest
* credential turns every destination-referencing output disabled so no new
* go-live can redeem it. Returns how many outputs were disabled.
*/
async disableDependentsOfCredential(workspaceId: string, credentialId: string): Promise<number> {
return this.deps.transaction(workspaceId, async (client) => {
const result = await client.query(
`UPDATE stream_outputs o SET enabled = false, updated_at = now()
FROM live_stream_destinations d
WHERE o.workspace_id = $1 AND o.destination_id = d.id AND d.workspace_id = $1
AND d.credential_id = $2 AND o.enabled
RETURNING o.id`,
[workspaceId, credentialId],
);
return result.rows.length;
});
}
/**
* Go-live credential resolution (vault pattern of the publish credential
* accessor): destination -> credential row -> vault.open with tenant + handle
* AAD. Fails closed on a revoked credential, a cross-tenant handle, or a
* shape violation. Raw material never leaves this path.
*/
async resolveIngestCredential(workspaceId: string, destinationId: string): Promise<ResolvedIngestCredential> {
return this.deps.transaction(workspaceId, async (client) => {
const result = await client.query<{ id: string; provider: string; endpoint_url: string; credential_id: string | null }>(
"SELECT id, provider, endpoint_url, credential_id FROM live_stream_destinations WHERE workspace_id = $1 AND id = $2",
[workspaceId, destinationId],
);
const row = result.rows[0];
if (row === undefined) throw new NotFoundException("live stream destination not found");
if (row.credential_id === null) return { endpointUrl: row.endpoint_url, streamKey: "", provider: row.provider };
const credential = await client.query<{ ciphertext: Buffer; key_version: number; revoked_at: Date | null; kind: string }>(
"SELECT ciphertext, key_version, revoked_at, kind FROM credentials WHERE id = $1 AND workspace_id = $2",
[row.credential_id, workspaceId],
);
const cred = credential.rows[0];
if (cred === undefined) throw new NotFoundException("destination credential not found");
if (cred.revoked_at !== null) throw new ConflictException("destination credential is revoked");
const plaintext = await this.deps.encryption.open(workspaceId, row.id, { ciphertext: cred.ciphertext, keyVersion: cred.key_version });
const secrets: unknown = JSON.parse(plaintext.toString());
if (typeof secrets !== "object" || secrets === null || Array.isArray(secrets)) {
throw new ConflictException("destination credential payload is malformed");
}
const key = (secrets as Record<string, unknown>).stream_key;
if (key !== undefined && typeof key !== "string") throw new ConflictException("destination credential payload is malformed");
return { endpointUrl: row.endpoint_url, streamKey: typeof key === "string" ? key : "", provider: row.provider };
});
}
private async assertLiveCredential(client: PoolClient, workspaceId: string, credentialId: string): Promise<void> {
const result = await client.query<{ revoked_at: Date | null }>(
"SELECT revoked_at FROM credentials WHERE id = $1 AND workspace_id = $2",
[credentialId, workspaceId],
);
const row = result.rows[0];
if (row === undefined) throw new BadRequestException("credentialId does not reference a credential in this workspace");
if (row.revoked_at !== null) throw new BadRequestException("credentialId references a revoked credential");
}
private async audit(client: PoolClient, workspaceId: string, actor: { userId: string | null; role: string },
action: string, targetId: string, diff: Record<string, unknown>): Promise<void> {
await client.query("SELECT audit_append($1,$2,$3,$4,$5,$6,$7)",
[workspaceId, actor.userId, actor.role, action, "live_destination", targetId, scrub(diff)]);
}
}

View file

@ -0,0 +1,331 @@
import { randomUUID } from "node:crypto";
import type { PoolClient } from "pg";
import { ConflictException, NotFoundException } from "@nestjs/common";
import { describe, expect, it } from "vitest";
import { Vault } from "@tactical-media-broadcast-hub/connector-runtime";
import {
LiveDestinationRepository, parseCreateDestination, parseUpdateDestination, type TenantTransaction,
} from "./live-destination.repository.js";
import { GoLiveService } from "./go-live.service.js";
import { createGenericHlsResolver, type LiveSourceResolver } from "./live-source-resolver.js";
/**
* Live-destination lifecycle (WP6/WP9 aggregate boundary) against an
* in-memory tenant-transaction double: delete-blocked-while-referenced,
* credential revoke disables dependents, and go-live resolution of
* StreamOutput -> LiveStreamDestination -> credential via the vault.
*/
interface DestinationRow {
id: string; workspace_id: string; label: string; provider: string; endpoint_url: string;
credential_id: string | null; last_platform_state: string; created_at: Date; updated_at: Date;
}
interface OutputRow {
id: string; workspace_id: string; virtual_channel_id: string; label: string; profile: string;
mode: string; enabled: boolean; destination_id: string | null; credential_handle_id: string | null;
created_at: Date; updated_at: Date;
}
interface CredentialRow { id: string; workspace_id: string; ciphertext: Buffer; key_version: number; revoked_at: Date | null }
const WS = randomUUID();
const ACTOR = { userId: randomUUID(), role: "Owner" };
function store(): {
destinations: DestinationRow[]; outputs: OutputRow[]; credentials: CredentialRow[];
transaction: TenantTransaction; client: PoolClient;
} {
const destinations: DestinationRow[] = [];
const outputs: OutputRow[] = [];
const credentials: CredentialRow[] = [];
const audit: unknown[][] = [];
const transaction: TenantTransaction = async <T>(_ws: string, fn: (client: PoolClient) => Promise<T>): Promise<T> => {
const query = async (sql: string, args: unknown[]): Promise<{ rows: Record<string, unknown>[]; rowCount: number }> => {
const table = sql.includes("FROM live_stream_destinations") || sql.includes("INTO live_stream_destinations") || sql.includes("UPDATE live_stream_destinations") || sql.includes("DELETE FROM live_stream_destinations") ? "destinations"
: sql.includes("stream_outputs") ? "outputs"
: sql.includes("FROM credentials") || sql.includes("UPDATE credentials") ? "credentials" : "other";
if (sql.startsWith("SELECT")) {
if (table === "destinations" && sql.includes("LEFT JOIN credentials")) {
const rows = destinations.filter(d => sql.includes("$2") ? d.id === args[1] : d.workspace_id === args[0])
.map(d => ({ ...d, credential_revoked_at: credentials.find(c => c.id === d.credential_id)?.revoked_at ?? null }));
return { rows: rows as unknown as Record<string, unknown>[], rowCount: rows.length };
}
if (table === "destinations") {
const rows = destinations.filter(d => d.id === args[1] && d.workspace_id === args[0]);
return { rows: rows as unknown as Record<string, unknown>[], rowCount: rows.length };
}
if (table === "outputs") {
const rows = outputs.filter(o => o.workspace_id === args[0] && (sql.includes("destination_id") ? o.destination_id === args[1] : o.virtual_channel_id === args[1]));
return { rows: rows as unknown as Record<string, unknown>[], rowCount: rows.length };
}
if (table === "credentials") {
const rows = credentials.filter(c => c.id === args[0] && c.workspace_id === args[1]);
return { rows: rows as unknown as Record<string, unknown>[], rowCount: rows.length };
}
if (sql.includes("workspaces") || sql.includes("virtual_channels") || sql.includes("audit_log")) {
return { rows: [{ id: args[0] ?? randomUUID() }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
if (sql.startsWith("INSERT INTO live_stream_destinations")) {
const row: DestinationRow = { id: randomUUID(), workspace_id: String(args[0]), label: String(args[1]),
provider: String(args[2]), endpoint_url: String(args[3]), credential_id: args[4] === null ? null : String(args[4]),
last_platform_state: "unknown", created_at: new Date(), updated_at: new Date() };
destinations.push(row);
return { rows: [row as unknown as Record<string, unknown>], rowCount: 1 };
}
if (sql.startsWith("INSERT INTO stream_outputs")) {
const row: OutputRow = { id: randomUUID(), workspace_id: String(args[0]), virtual_channel_id: String(args[1]),
label: String(args[2]), profile: String(args[3]), mode: String(args[4]), enabled: Boolean(args[5]),
destination_id: args[6] === null ? null : String(args[6]), credential_handle_id: args[7] === null ? null : String(args[7]),
created_at: new Date(), updated_at: new Date() };
outputs.push(row);
return { rows: [row as unknown as Record<string, unknown>], rowCount: 1 };
}
if (sql.startsWith("UPDATE stream_outputs SET enabled = false")) {
const ids = outputs.filter(o => o.destination_id === args[1] && o.enabled).map(o => { o.enabled = false; o.updated_at = new Date(); return { id: o.id }; });
return { rows: ids, rowCount: ids.length };
}
if (sql.startsWith("UPDATE stream_outputs o SET enabled = false")) {
const ids = outputs.filter(o => { const d = destinations.find(x => x.id === o.destination_id); return d !== undefined && d.credential_id === args[1] && o.enabled; })
.map(o => { o.enabled = false; o.updated_at = new Date(); return { id: o.id }; });
return { rows: ids, rowCount: ids.length };
}
if (sql.startsWith("UPDATE stream_outputs")) {
const target = outputs.find(o => o.id === args[10] && o.workspace_id === args[0]);
if (target === undefined) return { rows: [], rowCount: 0 };
if (args[2] !== null) target.label = String(args[2]);
if (args[3] !== null) target.enabled = Boolean(args[3]);
return { rows: [target as unknown as Record<string, unknown>], rowCount: 1 };
}
if (sql.startsWith("UPDATE live_stream_destinations")) {
const target = destinations.find(d => d.id === args[1] && d.workspace_id === args[0]);
if (target === undefined) return { rows: [], rowCount: 0 };
if (args[2] !== null) target.label = String(args[2]);
if (args[3] !== null) target.provider = String(args[3]);
if (args[4] !== null) target.endpoint_url = String(args[4]);
if (args[6]) target.credential_id = args[7] === null ? null : String(args[7]);
if (args[7] !== null) target.last_platform_state = String(args[7]);
target.updated_at = new Date();
return { rows: [target as unknown as Record<string, unknown>], rowCount: 1 };
}
if (sql.startsWith("UPDATE credentials SET revoked_at")) {
const target = credentials.find(c => c.id === args[0] && c.workspace_id === args[1] && c.revoked_at === null);
if (target !== undefined) target.revoked_at = new Date();
return { rows: [], rowCount: 1 };
}
if (sql.startsWith("DELETE FROM live_stream_destinations")) {
const index = destinations.findIndex(d => d.id === args[1] && d.workspace_id === args[0]);
if (index === -1) return { rows: [], rowCount: 0 };
const [row] = destinations.splice(index, 1);
return { rows: [row as unknown as Record<string, unknown>], rowCount: 1 };
}
if (sql.startsWith("DELETE FROM stream_outputs")) {
const index = outputs.findIndex(o => o.id === args[2] && o.workspace_id === args[0]);
if (index === -1) return { rows: [], rowCount: 0 };
outputs.splice(index, 1);
return { rows: [], rowCount: 1 };
}
if (sql.startsWith("SELECT audit_append")) { audit.push(args); return { rows: [{ id: randomUUID() }], rowCount: 1 }; }
return { rows: [], rowCount: 0 };
};
return fn({ query } as unknown as PoolClient);
};
return { destinations, outputs, credentials, transaction, client: {} as PoolClient };
}
function repository(over: Partial<ConstructorParameters<typeof LiveDestinationRepository>[0]> = {}): LiveDestinationRepository {
return new LiveDestinationRepository(over);
}
describe("live stream destination CRUD + lifecycle", () => {
it("creates, lists, updates and reads destinations", async () => {
const s = store();
const repo = repository({ transaction: s.transaction });
const created = await repo.create(WS, parseCreateDestination({ label: "Twitch main", provider: "twitch", endpointUrl: "rtmps://live.example.net/app", credentialId: null }), ACTOR);
expect(created.label).toBe("Twitch main");
expect(created.provider).toBe("twitch");
const listed = await repo.list(WS);
expect(listed).toHaveLength(1);
const updated = await repo.update(WS, created.id, parseUpdateDestination({ label: "Twitch backup", lastPlatformState: "live" }), ACTOR);
expect(updated.label).toBe("Twitch backup");
expect(updated.lastPlatformState).toBe("live");
await expect(repo.get(WS, created.id)).resolves.toMatchObject({ label: "Twitch backup" });
});
it("rejects bad bodies at parse time (label, provider, endpoint shape)", () => {
expect(() => parseCreateDestination({ label: "", provider: "twitch", endpointUrl: "rtmps://live.example.net/app" })).toThrow("label");
expect(() => parseCreateDestination({ label: "x", provider: "Twitch!", endpointUrl: "rtmps://live.example.net/app" })).toThrow("provider");
expect(() => parseCreateDestination({ label: "x", provider: "twitch", endpointUrl: "https://live.example.net/app" })).toThrow("endpointUrl");
expect(() => parseCreateDestination({ label: "x", provider: "twitch", endpointUrl: "rtmp://user:pw@live.example.net/app" })).toThrow("endpointUrl");
expect(() => parseUpdateDestination({})).toThrow("no recognized fields");
expect(() => parseUpdateDestination({ lastPlatformState: "exploded" })).toThrow("lastPlatformState");
});
it("delete is blocked (409) while an enabled output references the destination", async () => {
const s = store();
const repo = repository({ transaction: s.transaction });
const destination = await repo.create(WS, parseCreateDestination({ label: "D", provider: "twitch", endpointUrl: "rtmps://live.example.net/app" }), ACTOR);
s.outputs.push({ id: randomUUID(), workspace_id: WS, virtual_channel_id: randomUUID(), label: "O", profile: "720p30",
mode: "passthrough", enabled: true, destination_id: destination.id, credential_handle_id: null,
created_at: new Date(), updated_at: new Date() });
await expect(repo.remove(WS, destination.id, ACTOR)).rejects.toThrow(ConflictException);
await expect(repo.remove(WS, destination.id, ACTOR)).rejects.toThrow(/referenced by 1 enabled/);
expect(s.destinations).toHaveLength(1);
});
it("delete proceeds once the referencing output is disabled: dependents disabled, credential revoked, row removed", async () => {
const s = store();
const repo = repository({ transaction: s.transaction });
const credential = randomUUID();
s.credentials.push({ id: credential, workspace_id: WS, ciphertext: Buffer.alloc(8), key_version: 1, revoked_at: null });
const destination = await repo.create(WS, parseCreateDestination({ label: "D", provider: "twitch", endpointUrl: "rtmps://live.example.net/app", credentialId: credential }), ACTOR);
s.outputs.push({ id: randomUUID(), workspace_id: WS, virtual_channel_id: randomUUID(), label: "O", profile: "720p30",
mode: "passthrough", enabled: false, destination_id: destination.id, credential_handle_id: null,
created_at: new Date(), updated_at: new Date() });
const result = await repo.remove(WS, destination.id, ACTOR);
expect(result).toEqual({ disabledOutputs: 0, revokedCredential: true });
expect(s.destinations).toHaveLength(0);
expect(s.credentials[0]?.revoked_at).not.toBeNull();
await expect(repo.get(WS, destination.id)).rejects.toThrow(NotFoundException);
});
it("credential revoke disables dependents (outputs referencing a destination on that credential)", async () => {
const s = store();
const repo = repository({ transaction: s.transaction });
const credential = randomUUID();
s.credentials.push({ id: credential, workspace_id: WS, ciphertext: Buffer.alloc(8), key_version: 1, revoked_at: null });
const destination = await repo.create(WS, parseCreateDestination({ label: "D", provider: "peertube", endpointUrl: "rtmp://live.example.net/app", credentialId: credential }), ACTOR);
const channel = randomUUID();
s.outputs.push({ id: randomUUID(), workspace_id: WS, virtual_channel_id: channel, label: "A", profile: "720p30",
mode: "passthrough", enabled: true, destination_id: destination.id, credential_handle_id: null, created_at: new Date(), updated_at: new Date() });
s.outputs.push({ id: randomUUID(), workspace_id: WS, virtual_channel_id: channel, label: "B", profile: "720p30",
mode: "passthrough", enabled: true, destination_id: destination.id, credential_handle_id: null, created_at: new Date(), updated_at: new Date() });
const disabled = await repo.disableDependentsOfCredential(WS, credential);
expect(disabled).toBe(2);
expect(s.outputs.every(o => !o.enabled)).toBe(true);
});
it("refuses a credential from another workspace or a revoked one on create", async () => {
const s = store();
const repo = repository({ transaction: s.transaction });
const foreign = randomUUID();
s.credentials.push({ id: foreign, workspace_id: randomUUID(), ciphertext: Buffer.alloc(8), key_version: 1, revoked_at: null });
await expect(repo.create(WS, parseCreateDestination({ label: "D", provider: "twitch", endpointUrl: "rtmps://live.example.net/app", credentialId: foreign }), ACTOR))
.rejects.toThrow(/credential in this workspace/);
const revoked = randomUUID();
s.credentials.push({ id: revoked, workspace_id: WS, ciphertext: Buffer.alloc(8), key_version: 1, revoked_at: new Date() });
await expect(repo.create(WS, parseCreateDestination({ label: "D", provider: "twitch", endpointUrl: "rtmps://live.example.net/app", credentialId: revoked }), ACTOR))
.rejects.toThrow(/revoked/);
});
});
describe("go-live wiring (StreamOutput -> destination -> vault credential)", () => {
const CHANNEL = randomUUID();
function vaultFixture(): { vault: Vault; secrets: Map<string, string> } {
const real = new Vault();
const secrets = new Map<string, string>();
const vault = {
async seal(workspaceId: string, id: string, plaintext: Buffer) { return real.seal(workspaceId, id, plaintext); },
async open(workspaceId: string, id: string, _sealed: { ciphertext: Buffer; keyVersion: number }) {
return Buffer.from(secrets.get(`${workspaceId}:${id}`) ?? "");
},
} as unknown as Vault;
return { vault, secrets };
}
it("resolves enabled destination-referenced outputs through the destination and its credential", async () => {
const s = store();
const { vault, secrets } = vaultFixture();
const repo = repository({ transaction: s.transaction, encryption: vault });
const credential = randomUUID();
s.credentials.push({ id: credential, workspace_id: WS, ciphertext: Buffer.alloc(8), key_version: 1, revoked_at: null });
const destination = await repo.create(WS, parseCreateDestination({ label: "D", provider: "twitch", endpointUrl: "rtmps://live.example.net/app", credentialId: credential }), ACTOR);
s.outputs.push({ id: randomUUID(), workspace_id: WS, virtual_channel_id: CHANNEL, label: "A", profile: "1080p30",
mode: "normalize", enabled: true, destination_id: destination.id, credential_handle_id: null, created_at: new Date(), updated_at: new Date() });
s.outputs.push({ id: randomUUID(), workspace_id: WS, virtual_channel_id: CHANNEL, label: "B", profile: "720p30",
mode: "passthrough", enabled: false, destination_id: destination.id, credential_handle_id: null, created_at: new Date(), updated_at: new Date() });
const goLive = new GoLiveService(repo, { transaction: s.transaction });
const targets = await goLive.resolveOutputs(WS, CHANNEL);
expect(targets).toHaveLength(1);
expect(targets[0]).toMatchObject({ endpointUrl: "rtmps://live.example.net/app", throughDestination: true, credentialHandleId: credential });
expect(targets[0]?.output.label).toBe("A");
// Redeem path: vault.open with tenant+handle AAD, raw key surfaced only here.
secrets.set(`${WS}:${destination.id}`, JSON.stringify({ stream_key: "live_123" }));
const redeemed = await goLive.redeemDestinationCredential(WS, destination.id);
expect(redeemed).toEqual({ endpointUrl: "rtmps://live.example.net/app", streamKey: "live_123", provider: "twitch" });
});
it("fails closed when the destination credential is revoked or the destination has none", async () => {
const s = store();
const { vault } = vaultFixture();
const repo = repository({ transaction: s.transaction, encryption: vault });
const credential = randomUUID();
s.credentials.push({ id: credential, workspace_id: WS, ciphertext: Buffer.alloc(8), key_version: 1, revoked_at: new Date() });
// The repository (correctly) refuses to create a destination on a revoked
// credential, so this destination was revoked after creation.
const revokedDestination = { id: randomUUID(), workspace_id: WS, label: "R", provider: "twitch",
endpoint_url: "rtmps://live.example.net/app", credential_id: credential, last_platform_state: "unknown",
created_at: new Date(), updated_at: new Date() };
s.destinations.push(revokedDestination);
s.outputs.push({ id: randomUUID(), workspace_id: WS, virtual_channel_id: CHANNEL, label: "A", profile: "720p30",
mode: "passthrough", enabled: true, destination_id: revokedDestination.id, credential_handle_id: null, created_at: new Date(), updated_at: new Date() });
const goLive = new GoLiveService(repo, { transaction: s.transaction });
await expect(goLive.resolveOutputs(WS, CHANNEL)).rejects.toThrow(/revoked/);
await expect(goLive.redeemDestinationCredential(WS, revokedDestination.id)).rejects.toThrow(/revoked/);
});
it("resolves the program source through provider resolvers and refuses non-live sources", async () => {
const s = store();
const repo = repository({ transaction: s.transaction });
const seen: string[] = [];
const goLive = new GoLiveService(repo, {
transaction: s.transaction,
sourceResolvers: new Map<string, LiveSourceResolver>([
["twitch", async (request) => { seen.push(request.channelId ?? ""); return { playbackHlsUrl: "https://usher.example.com/a.m3u8", live: true }; }],
["offline-provider", async () => ({ playbackHlsUrl: null, live: false })],
]),
});
const resolved = await goLive.resolveProgramSource(WS, { provider: "twitch", channelId: "someone" });
expect(resolved).toEqual({ playbackHlsUrl: "https://usher.example.com/a.m3u8", live: true, provider: "twitch" });
expect(seen).toEqual(["someone"]);
await expect(goLive.resolveProgramSource(WS, { provider: "offline-provider", channelId: "x" })).rejects.toThrow(/not live/);
await expect(goLive.resolveProgramSource(WS, { provider: "nope", channelId: "x" })).rejects.toThrow(NotFoundException);
});
it("builds a RUNNING DesiredSpec for the take/start path from outputs + resolved source", async () => {
const s = store();
const repo = repository({ transaction: s.transaction });
const destination = await repo.create(WS, parseCreateDestination({ label: "D", provider: "twitch", endpointUrl: "rtmps://live.example.net/app" }), ACTOR);
s.credentials.push({ id: "cred-go-live", workspace_id: WS, ciphertext: Buffer.alloc(8), key_version: 1, revoked_at: null });
s.destinations.forEach(d => { if (d.id === destination.id) d.credential_id = "cred-go-live"; });
s.outputs.push({ id: randomUUID(), workspace_id: WS, virtual_channel_id: CHANNEL, label: "A", profile: "1080p60",
mode: "normalize", enabled: true, destination_id: destination.id, credential_handle_id: null, created_at: new Date(), updated_at: new Date() });
const goLive = new GoLiveService(repo, {
transaction: s.transaction,
sourceResolvers: new Map([["twitch", async () => ({ playbackHlsUrl: "https://usher.example.com/a.m3u8", live: true })]]),
});
const spec = await goLive.desiredSpecForGoLive(WS, CHANNEL, { provider: "twitch", channelId: "someone" });
expect(spec).toEqual({
desiredStatus: "RUNNING",
source: { kind: "hls", url: "https://usher.example.com/a.m3u8" },
output: { endpointUrl: "rtmps://live.example.net/app", credentialHandleId: "cred-go-live" },
profile: "1080p60",
mode: "normalize",
});
// The spec is exactly MediaControlService.setDesired's DesiredSpec shape.
await expect(goLive.desiredSpecForGoLive(WS, randomUUID(), { provider: "twitch" })).rejects.toThrow(/no enabled stream outputs/);
});
it("generic-hls resolver uses the external URL directly and probes liveness", async () => {
const requests: string[] = [];
const resolver = createGenericHlsResolver(async (input) => { requests.push(String(input)); return new Response("", { status: 200 }); }, 250);
await expect(resolver({ externalUrl: "https://cdn.example.com/live/index.m3u8" }))
.resolves.toEqual({ playbackHlsUrl: "https://cdn.example.com/live/index.m3u8", live: true });
expect(requests).toEqual(["https://cdn.example.com/live/index.m3u8"]);
const failing = createGenericHlsResolver(async () => new Response("gone", { status: 404 }), 250);
await expect(failing({ externalUrl: "https://cdn.example.com/live/index.m3u8" }))
.resolves.toEqual({ playbackHlsUrl: "https://cdn.example.com/live/index.m3u8", live: false });
await expect(resolver({})).rejects.toThrow("externalUrl");
});
});

View file

@ -0,0 +1,72 @@
import type { LiveSourceResolution } from "@tactical-media-broadcast-hub/core";
import type { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
/**
* Source resolvers (WP6 go-live slice). Connector-level: each connector owns
* its provider behaviour; the app resolves by provider id through the
* connector registry and never switches on provider names.
*/
export interface LiveSourceRequest {
/** Provider channel identity (e.g. a Twitch login). */
channelId?: string;
/** External URL naming the same channel (provider-specific shape). */
externalUrl?: string;
}
export type LiveSourceResolver = (request: LiveSourceRequest) => Promise<LiveSourceResolution>;
export class LiveSourceNotFoundError extends Error {
constructor(readonly provider: string) { super(`no live source resolver for provider "${provider}"`); }
}
/**
* Registry-shaped resolver: resolves by provider id through the connector
* registry. `connection` supplies the credential context a connector needs
* (some connectors call Helix with a bound token; callers pass the connection
* associated with the destination/source being resolved).
*/
export function createLiveSourceResolver(registry: ConnectorRegistry): (provider: string, request: LiveSourceRequest, connectionId?: string) => Promise<LiveSourceResolution> {
return async (provider, request, connectionId) => {
if (!registry.has(provider)) throw new LiveSourceNotFoundError(provider);
const connector = registry.get(provider);
if (connector.resolveLiveSource === undefined) throw new LiveSourceNotFoundError(provider);
// A minimal connection carries the credential ref; connectors that need no
// token (generic-hls) ignore it.
const connection = {
id: connectionId ?? `${provider}:live-source`,
workspaceId: "live-source",
provider,
channelId: request.channelId ?? request.externalUrl ?? "",
credentialId: { kind: "credential-ref" as const, id: connectionId ?? `${provider}:live-source` },
fields: {},
createdAt: new Date(),
};
return connector.resolveLiveSource(connection, request);
};
}
/**
* generic-hls: the external URL is the playback URL. Liveness is probed with a
* bounded HEAD/GET of the playlist; 2xx/3xx and a 4xx/5xx without transport
* error are both definitive, only a transport failure reports unknown (live
* kept false and playbackUrl still returned so callers can poll).
*/
export function createGenericHlsResolver(fetchImpl: typeof fetch = fetch, timeoutMs = 5_000): LiveSourceResolver {
return async (request) => {
const url = request.externalUrl;
if (url === undefined || url.length === 0) throw new Error("generic-hls resolver requires externalUrl");
let live = false;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, { method: "GET", signal: controller.signal, redirect: "follow" });
live = response.ok;
} finally { clearTimeout(timer); }
} catch { live = false; }
return { playbackHlsUrl: url, live };
};
}
/** Registers the generic-hls pseudo connector id used by destinations/sources with no dedicated connector. */
export const GENERIC_HLS_PROVIDER = "generic-hls";

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { LiveModule } from "./live.module.js";
import { LiveDestinationRepository } from "./live-destination.repository.js";
import { GoLiveService } from "./go-live.service.js";
import { StreamOutputController } from "./stream-output.controller.js";
import { LiveDestinationController } from "./live-destination.controller.js";
import type { INestApplicationContext } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
/**
* Module wiring smoke test: LiveModule is importable without a database (the
* registry registration is env-gated) and provides its services/controllers
* with the DI graph intact.
*/
describe("LiveModule wiring", () => {
it("provides the repository, go-live service and controllers", async () => {
const hadDatabaseUrl = process.env.DATABASE_URL; // registry registration is DB-gated; module must still construct
delete process.env.DATABASE_URL;
const app: INestApplicationContext = await NestFactory.createApplicationContext(LiveModule, { logger: false });
try {
expect(app.get(LiveDestinationRepository)).toBeInstanceOf(LiveDestinationRepository);
const goLive = app.get(GoLiveService, { strict: false });
expect(goLive).toBeInstanceOf(GoLiveService);
expect(app.get(LiveDestinationController)).toBeInstanceOf(LiveDestinationController);
expect(app.get(StreamOutputController)).toBeInstanceOf(StreamOutputController);
} finally {
await app.close();
if (hadDatabaseUrl !== undefined) process.env.DATABASE_URL = hadDatabaseUrl;
}
});
});

View file

@ -0,0 +1,58 @@
import { Module } from "@nestjs/common";
import { withTenant } from "../db/client.js";
import { LiveDestinationController } from "./live-destination.controller.js";
import { LiveDestinationRepository, LIVE_DESTINATION_DEPENDENCIES, type TenantTransaction } from "./live-destination.repository.js";
import { GoLiveService, GO_LIVE_DEPENDENCIES } from "./go-live.service.js";
import { StreamOutputController } from "./stream-output.controller.js";
import { createPublishingRegistry } from "@tactical-media-broadcast-hub/connector-runtime";
import { pool } from "../db/client.js";
/** Registers connector-level live source resolvers from the connector registry. */
function registerSourceResolvers(goLive: GoLiveService): void {
// Registry pattern: the provider set comes from the publishing registry —
// connector packages (twitch et al.) own provider behaviour, so every
// connector implementing resolveLiveSource registers under its own manifest
// id; generic-hls resolves through the built-in prober. No provider names
// branch here.
if (process.env.DATABASE_URL === undefined) return;
try {
const registry = createPublishingRegistry(pool);
for (const connector of registry.list()) {
const { id } = connector.manifest();
if (connector.resolveLiveSource === undefined) continue;
const resolveLiveSource = connector.resolveLiveSource.bind(connector);
goLive.registerSourceResolver(id, async (request) => resolveLiveSource(
{
id: `${id}:live-source`, workspaceId: "live-source", provider: id,
channelId: request.channelId ?? request.externalUrl ?? "",
credentialId: { kind: "credential-ref", id: `${id}:live-source` },
fields: {}, createdAt: new Date(),
},
request,
));
}
} catch { /* No connectors configured in this process: only generic-hls stays registered. */ }
}
@Module({
controllers: [LiveDestinationController, StreamOutputController],
providers: [
{ provide: "STREAM_OUTPUT_TRANSACTION", useValue: withTenant satisfies TenantTransaction },
{
provide: LiveDestinationRepository,
useFactory: (dependencies?: unknown) => new LiveDestinationRepository(dependencies as never),
inject: [{ token: LIVE_DESTINATION_DEPENDENCIES, optional: true }],
},
{
provide: GoLiveService,
useFactory: (destinations: LiveDestinationRepository, dependencies?: unknown) => {
const service = new GoLiveService(destinations, dependencies as never);
registerSourceResolvers(service);
return service;
},
inject: [LiveDestinationRepository, { token: GO_LIVE_DEPENDENCIES, optional: true }],
},
],
exports: [LiveDestinationRepository, GoLiveService],
})
export class LiveModule {}

View file

@ -0,0 +1,162 @@
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;
}