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:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
171
apps/api/src/live/go-live.service.ts
Normal file
171
apps/api/src/live/go-live.service.ts
Normal 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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
62
apps/api/src/live/live-destination.controller.ts
Normal file
62
apps/api/src/live/live-destination.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
171
apps/api/src/live/live-destination.integration.test.ts
Normal file
171
apps/api/src/live/live-destination.integration.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
348
apps/api/src/live/live-destination.repository.ts
Normal file
348
apps/api/src/live/live-destination.repository.ts
Normal 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)]);
|
||||
}
|
||||
}
|
||||
331
apps/api/src/live/live-destination.test.ts
Normal file
331
apps/api/src/live/live-destination.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
72
apps/api/src/live/live-source-resolver.ts
Normal file
72
apps/api/src/live/live-source-resolver.ts
Normal 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";
|
||||
31
apps/api/src/live/live.module.test.ts
Normal file
31
apps/api/src/live/live.module.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
58
apps/api/src/live/live.module.ts
Normal file
58
apps/api/src/live/live.module.ts
Normal 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 {}
|
||||
162
apps/api/src/live/stream-output.controller.ts
Normal file
162
apps/api/src/live/stream-output.controller.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue