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
66
apps/api/src/live-chat/adapters.test.ts
Normal file
66
apps/api/src/live-chat/adapters.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockLivestreamConnector } from "@tactical-media-broadcast-hub/connector-mock-livestream";
|
||||
import { TwitchChatTransport, twitchToEvent } from "./twitch-adapter.js";
|
||||
import { SharedChatPollers } from "./shared-poller.js";
|
||||
import { SendGovernors } from "./governors.js";
|
||||
import { LiveChatHubService } from "./live-chat.service.js";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
describe("Twitch adapter with injected Helix", () => {
|
||||
it("maps the existing connector event and preserves reply/moderation arguments", async () => {
|
||||
const ws = randomUUID(), channelId = randomUUID(), connectionId = randomUUID();
|
||||
const options = { workspaceId: ws, channelId, connectionId, liveRemoteId: "remote" };
|
||||
expect(twitchToEvent(options, { id: "provider-msg", provider: "twitch", liveStreamId: "remote", authorHandle: "viewer", text: "hi", sentAt: new Date(0), isModerated: false })).toMatchObject({ channelId, connectionId, providerMessageId: "provider-msg", author: { handle: "viewer" } });
|
||||
const helix = { send: vi.fn(async (_request: unknown) => ({ providerMessageId: "sent", sentAt: new Date(1) })), moderate: vi.fn(async (_request: unknown) => undefined) };
|
||||
const transport = new TwitchChatTransport(createMockLivestreamConnector(), helix);
|
||||
await expect(transport.send({ connectionId, liveRemoteId: "remote", text: "reply", replyTo: "provider-msg" })).resolves.toMatchObject({ providerMessageId: "sent" });
|
||||
expect(helix.send).toHaveBeenCalledWith({ connectionId, liveRemoteId: "remote", text: "reply", replyTo: "provider-msg" });
|
||||
await transport.moderate({ connectionId, liveRemoteId: "remote", messageRemoteId: "provider-msg", authorHandle: "viewer", action: "timeout", durationSeconds: 600 });
|
||||
expect(helix.moderate).toHaveBeenCalledWith(expect.objectContaining({ action: "timeout", durationSeconds: 600, authorHandle: "viewer" }));
|
||||
});
|
||||
});
|
||||
describe("shared provider long poll", () => {
|
||||
it("shares one poll chain, charges list=1, and honors pollingIntervalMillis and cursor", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pollers = new SharedChatPollers(), fetch = vi.fn(async (_cursor?: string) => ({ events: [], nextPageToken: "next", pollingIntervalMillis: 5000 })), ingest = vi.fn(async () => ({ inserted: [], duplicates: [] }));
|
||||
const governors = new SendGovernors({ quotas: { youtube: { budget: 100, costPerCall: 50 } } });
|
||||
const options = { workspaceId: "ws", broadcastId: "live", provider: "youtube", fetch, ingest, governors };
|
||||
const one = pollers.acquire(options), two = pollers.acquire(options);
|
||||
await vi.advanceTimersByTimeAsync(0); expect(fetch).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(4999); expect(fetch).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1); expect(fetch).toHaveBeenCalledTimes(2); expect(fetch).toHaveBeenLastCalledWith("next");
|
||||
expect(governors.take("youtube").allowed).toBe(true); // two list units leave 98 units for a 50-unit send
|
||||
one(); two(); await vi.advanceTimersByTimeAsync(10000); expect(fetch).toHaveBeenCalledTimes(2); pollers.close();
|
||||
});
|
||||
it("never polls below quota and exposes paused reason", async () => {
|
||||
vi.useFakeTimers(); const fetch = vi.fn(), paused = vi.fn(), pollers = new SharedChatPollers();
|
||||
const release = pollers.acquire({ workspaceId: "ws", broadcastId: "live", provider: "youtube", fetch, paused, ingest: async () => ({ inserted: [], duplicates: [] }), governors: new SendGovernors({ quotas: { youtube: { budget: 0 } } }) });
|
||||
await vi.advanceTimersByTimeAsync(0); expect(fetch).not.toHaveBeenCalled(); expect(paused).toHaveBeenCalledWith("daily quota exhausted"); release();
|
||||
});
|
||||
});
|
||||
describe("hub display filters", () => {
|
||||
it("applies commands, bots, muted provider handles and words before sequencing", async () => {
|
||||
const transaction = vi.fn();
|
||||
const hub = new LiveChatHubService({ transaction, filters: { hideCommands: true, hideBots: true, mutedHandles: ["mock-livestream:muted"], words: ["blocked"] } });
|
||||
const base = { provider: "mock-livestream", connectionId: "connection", channelId: "channel", providerMessageId: "msg", author: { handle: "viewer" }, text: "hi", sentAt: new Date() };
|
||||
expect(await hub.ingest("ws", [{ ...base, text: "!command" }, { ...base, author: { handle: "bot", isBot: true } }, { ...base, author: { handle: "muted" } }, { ...base, text: "BLOCKED word" }])).toEqual({ inserted: [], duplicates: [] });
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry capability probing and comments fallback", () => {
|
||||
it("degrades to read-only embed, then labelled comments, without claiming XMPP support", async () => {
|
||||
const { probeChatCapability } = await import("./capability-probe.js");
|
||||
const { MockLivestreamDriver } = await import("./mock-driver.js");
|
||||
const connector = createMockLivestreamConnector(), manifest = connector.manifest();
|
||||
connector.manifest = () => ({ ...manifest, capabilities: { ...manifest.capabilities, liveChatRead: false, comments: true } });
|
||||
connector.getComments = async () => ({ items: [{ id: "comment", provider: manifest.id, remoteId: "comment", onRemoteId: "live", authorHandle: "viewer", text: "comment text", createdAt: new Date(0) }] });
|
||||
const connection = { id: randomUUID(), workspaceId: randomUUID(), provider: manifest.id, channelId: "remote", credentialId: { kind: "credential-ref" as const, id: "ref" }, fields: {}, createdAt: new Date() };
|
||||
expect(probeChatCapability(connector, connection, "https://chat.example/embed")).toMatchObject({ mode: "readonly-embed", capability: { reply: false, moderate: false } });
|
||||
expect(probeChatCapability(connector, connection)).toMatchObject({ mode: "comments-only", capability: { read: true, state: "degraded", reason: "comments only" } });
|
||||
const driver = new MockLivestreamDriver(connector, { workspaceId: connection.workspaceId, channelId: "live", connectionId: connection.id, liveRemoteId: "live", connection, commentsOnly: true });
|
||||
const ingest = vi.fn(async () => ({ inserted: [], duplicates: [] }));
|
||||
await driver.start(ingest); expect(ingest).toHaveBeenCalledWith(connection.workspaceId, [expect.objectContaining({ source: "social-reply", text: "comment text" })]); await driver.stop();
|
||||
});
|
||||
});
|
||||
37
apps/api/src/live-chat/auth.ts
Normal file
37
apps/api/src/live-chat/auth.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { PoolClient } from "pg";
|
||||
import { verifyDeviceToken } from "./devices.js";
|
||||
import { verifySession, type SessionPayload } from "../auth/session.js";
|
||||
import { isUuid } from "../http/validation.js";
|
||||
|
||||
export function chatSession(token: string): SessionPayload | null { try { return verifySession(token); } catch { return null; } }
|
||||
|
||||
export function tokenWorkspace(token: string): string | null {
|
||||
return chatSession(token)?.workspaceId ?? verifyDeviceToken(token)?.workspaceId ?? null;
|
||||
}
|
||||
/** Current membership and workspace epoch always fence both credential kinds. */
|
||||
export async function authorizeChat(client: PoolClient, token: string, workspaceId: string, permission: string): Promise<SessionPayload | null> {
|
||||
const session = chatSession(token);
|
||||
const device = session ? null : verifyDeviceToken(token);
|
||||
const principal = session ?? device;
|
||||
if (!principal || principal.workspaceId !== workspaceId || !isUuid(workspaceId) || !isUuid(principal.userId)) return null;
|
||||
await client.query("BEGIN");
|
||||
try {
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
|
||||
const member = await client.query<{ role: string; authz_epoch: string | number }>(
|
||||
`SELECT m.role, w.authz_epoch FROM memberships m JOIN workspaces w ON w.id=m.workspace_id
|
||||
JOIN role_permissions rp ON rp.role=m.role AND rp.permission=$3
|
||||
WHERE m.workspace_id=$1 AND m.user_id=$2 AND m.status='active'`, [workspaceId, principal.userId, permission]);
|
||||
const row = member.rows[0];
|
||||
if (!row || !Number.isSafeInteger(Number(row.authz_epoch))) return null;
|
||||
if (session && session.authzEpoch !== Number(row.authz_epoch)) return null;
|
||||
if (device) {
|
||||
if (!device.scopes.includes(permission)) return null;
|
||||
const found = await client.query<{ epoch: string | number; scopes: string[]; authz_epoch: string | number }>(
|
||||
"SELECT epoch, scopes, authz_epoch FROM devices WHERE workspace_id=$1 AND id=$2 AND user_id=$3 AND revoked_at IS NULL",
|
||||
[workspaceId, device.deviceId, device.userId]);
|
||||
const stored = found.rows[0];
|
||||
if (!stored || Number(stored.epoch) !== device.epoch || Number(stored.authz_epoch) !== Number(row.authz_epoch) || !stored.scopes.includes(permission)) return null;
|
||||
}
|
||||
return { userId: principal.userId, workspaceId, role: row.role, authzEpoch: Number(row.authz_epoch), issuedAt: principal.issuedAt, expiresAt: principal.expiresAt };
|
||||
} finally { await client.query("ROLLBACK"); }
|
||||
}
|
||||
29
apps/api/src/live-chat/bridge.ts
Normal file
29
apps/api/src/live-chat/bridge.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Injectable, Inject, Optional, OnModuleDestroy } from "@nestjs/common";
|
||||
import { RespControlRedis, type ControlRedis } from "../media-control/redis.js";
|
||||
import type { LiveChatPublish } from "./live-chat.service.js";
|
||||
|
||||
export const CHAT_SINK = Symbol("CHAT_SINK");
|
||||
export type ChatSink = LiveChatPublish;
|
||||
export const chatTopic = (workspaceId: string, liveId: string): string => `chat:${workspaceId}:${liveId}`;
|
||||
export const chatWindowKey = (workspaceId: string, liveId: string): string => `${chatTopic(workspaceId, liveId)}:window`;
|
||||
// Atomic durable resume window + notification. Scores preserve hub ingress order.
|
||||
export const CHAT_PUBLISH_SCRIPT = `
|
||||
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
|
||||
local count = redis.call('ZCARD', KEYS[1])
|
||||
if count > 500 then redis.call('ZREMRANGEBYRANK', KEYS[1], 0, count - 501) end
|
||||
redis.call('EXPIRE', KEYS[1], 86400)
|
||||
return redis.call('PUBLISH', KEYS[2], ARGV[2])`;
|
||||
export const CHAT_WINDOW_SCRIPT = `return redis.call('ZRANGE', KEYS[1], 0, -1)`;
|
||||
|
||||
@Injectable()
|
||||
export class ChatRealtimeBridge implements OnModuleDestroy {
|
||||
private readonly redis: ControlRedis;
|
||||
constructor(@Optional() @Inject(CHAT_SINK) private readonly sink?: ChatSink, redis?: ControlRedis) {
|
||||
this.redis = redis ?? new RespControlRedis(process.env.REDIS_URL ?? "redis://localhost:6379");
|
||||
}
|
||||
publish: LiveChatPublish = async (workspaceId, channelId, frame): Promise<void> => {
|
||||
if (this.sink) { await this.sink(workspaceId, channelId, frame); return; }
|
||||
await this.redis.eval(CHAT_PUBLISH_SCRIPT, [chatWindowKey(workspaceId, channelId), chatTopic(workspaceId, channelId)], [String(frame.seq), JSON.stringify(frame)]);
|
||||
};
|
||||
onModuleDestroy(): void { this.redis.close(); }
|
||||
}
|
||||
22
apps/api/src/live-chat/capability-probe.ts
Normal file
22
apps/api/src/live-chat/capability-probe.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { ConnectorConnection, LiveChatCapability } from "@tactical-media-broadcast-hub/core";
|
||||
import type { PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
||||
export interface ChatCapabilityProbe {
|
||||
capability: LiveChatCapability;
|
||||
mode: "native" | "readonly-embed" | "comments-only" | "none";
|
||||
embedUrl?: string;
|
||||
}
|
||||
/** Registry-based probing covers PeerTube degradation without provider branches.
|
||||
* XMPP is intentionally reserved for a later registered native adapter. */
|
||||
export function probeChatCapability(connector: PlatformConnector, connection: ConnectorConnection, embedUrl?: string): ChatCapabilityProbe {
|
||||
const manifest = connector.manifest(), caps = manifest.capabilities;
|
||||
const base = { connectionId: connection.id, provider: manifest.id };
|
||||
if (caps.liveChatRead && connector.getLiveChat) return { mode: "native", capability: { ...base, read: true, reply: Boolean(caps.liveChatWrite && connector.sendLiveChatMessage), moderate: Boolean(caps.liveChatModeration && connector.moderateLiveChat), state: "ok" } };
|
||||
if (embedUrl) {
|
||||
const url = new URL(embedUrl);
|
||||
if (url.protocol !== "https:") throw new Error("read-only chat embed must use HTTPS");
|
||||
return { mode: "readonly-embed", embedUrl, capability: { ...base, read: true, reply: false, moderate: false, state: "degraded", reason: "read-only web embed" } };
|
||||
}
|
||||
if (caps.comments && connector.getComments) return { mode: "comments-only", capability: { ...base, read: true, reply: Boolean(connector.reply), moderate: false, state: "degraded", reason: "comments only" } };
|
||||
return { mode: "none", capability: { ...base, read: false, reply: false, moderate: false, state: "degraded", reason: "chat unavailable" } };
|
||||
}
|
||||
14
apps/api/src/live-chat/chat-fanout.ts
Normal file
14
apps/api/src/live-chat/chat-fanout.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Gateway-side chat sink: lets the live-chat hub push `kind:"chat"` frames to
|
||||
* the /realtime gateway WITHOUT the hub importing the gateway (S3 owns the
|
||||
* gateway; the slice boundary stays clean and no cycle forms).
|
||||
*
|
||||
* The realtime module registers an implementation at bootstrap. Chat frames
|
||||
* ride the same per-channel topic as state events: the same subscribe/ack
|
||||
* handshake, the same bounded resume ring, the same authz eviction. This file
|
||||
* only declares the port.
|
||||
*/
|
||||
import type { LiveChatPublish } from "../live-chat/live-chat.service.js";
|
||||
|
||||
export const CHAT_FANOUT = Symbol("CHAT_FANOUT");
|
||||
export type ChatFanout = LiveChatPublish;
|
||||
69
apps/api/src/live-chat/devices.test.ts
Normal file
69
apps/api/src/live-chat/devices.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { PoolClient } from "pg";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEVICE_SCOPES } from "@tactical-media-broadcast-hub/core";
|
||||
import { DeviceService, issueDeviceToken, verifyDeviceToken } from "./devices.js";
|
||||
import { authorizeChat } from "./auth.js";
|
||||
|
||||
const WS = randomUUID(), USER = randomUUID();
|
||||
function fixture() {
|
||||
const pairings: Array<{ workspace_id: string; user_id: string; code: string; scopes: string[]; expires_at: Date; redeemed_at: Date | null; device_id: string | null }> = [];
|
||||
const devices = new Map<string, { epoch: number; revoked_at: Date | null }>();
|
||||
let now = Date.now();
|
||||
const client = { query: async (sql: string, args: unknown[]) => {
|
||||
if (sql.includes("INSERT INTO device_pairings")) {
|
||||
pairings.push({ workspace_id: String(args[0]), user_id: String(args[1]), code: String(args[2]), scopes: args[5] as string[], expires_at: args[4] as Date, redeemed_at: null, device_id: null }); return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes("SELECT * FROM device_pairings")) return { rows: pairings.filter(row => row.workspace_id === args[0] && row.code === args[1]) };
|
||||
if (sql.includes("INSERT INTO devices")) { const id = randomUUID(); devices.set(id, { epoch: 1, revoked_at: null }); return { rows: [{ id }] }; }
|
||||
if (sql.includes("UPDATE device_pairings")) { const row = pairings.find(row => row.code === args[1]); if (row) { row.redeemed_at = new Date(now); row.device_id = String(args[2]); } return { rows: [] }; }
|
||||
if (sql.includes("UPDATE devices")) { const row = devices.get(String(args[1])); if (!row || row.revoked_at) return { rows: [], rowCount: 0 }; row.epoch += 1; row.revoked_at = new Date(now); return { rows: [row], rowCount: 1 }; }
|
||||
if (sql.includes("SELECT epoch")) { const row = devices.get(String(args[1])); return { rows: row ? [row] : [] }; }
|
||||
throw new Error(`unexpected device SQL: ${sql}`);
|
||||
} } as unknown as PoolClient;
|
||||
let pending = Promise.resolve();
|
||||
const service = new DeviceService({ serverUrl: "https://studio.example", now: () => new Date(now), transaction: async (_ws, fn) => {
|
||||
const previous = pending; let release: () => void = () => undefined;
|
||||
pending = new Promise<void>(resolve => { release = resolve; }); await previous;
|
||||
try { return await fn(client); } finally { release(); }
|
||||
} });
|
||||
return { service, advance: (ms: number) => { now += ms; } };
|
||||
}
|
||||
describe("device pairing and revocation", () => {
|
||||
it("code → signed scoped token, wrong workspace rejected, single-use code, revoke fences epoch", async () => {
|
||||
const f = fixture(), issued = await f.service.issuePairing(WS, USER);
|
||||
expect(JSON.parse(issued.qrPayload)).toEqual({ serverUrl: "https://studio.example", pairingCode: issued.pairingCode });
|
||||
await expect(f.service.redeem(randomUUID(), issued.pairingCode, "phone")).rejects.toThrow("invalid pairing code");
|
||||
const result = await f.service.redeem(WS, issued.pairingCode, "phone");
|
||||
const claims = verifyDeviceToken(result.deviceToken);
|
||||
expect(claims).toMatchObject({ workspaceId: WS, userId: USER, scopes: [...DEVICE_SCOPES], epoch: 1 });
|
||||
if (!claims) throw new Error("missing token claims");
|
||||
expect(await f.service.epochOf(WS, claims.deviceId)).toBe(1);
|
||||
await expect(f.service.redeem(WS, issued.pairingCode, "other")).rejects.toThrow("already redeemed");
|
||||
await f.service.revoke(WS, claims.deviceId); expect(await f.service.epochOf(WS, claims.deviceId)).toBeNull();
|
||||
});
|
||||
it("expires at five minutes, including the boundary", async () => {
|
||||
const f = fixture(), issued = await f.service.issuePairing(WS, USER);
|
||||
f.advance(300000); await expect(f.service.redeem(WS, issued.pairingCode, "phone")).rejects.toThrow("expired");
|
||||
});
|
||||
it("only one concurrent redemption succeeds", async () => {
|
||||
const f = fixture(), issued = await f.service.issuePairing(WS, USER);
|
||||
const results = await Promise.allSettled([f.service.redeem(WS, issued.pairingCode, "one"), f.service.redeem(WS, issued.pairingCode, "two")]);
|
||||
expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
|
||||
});
|
||||
it("rejects tampering, expired tokens, and malformed signed claims", () => {
|
||||
const token = issueDeviceToken({ deviceId: randomUUID(), workspaceId: WS, userId: USER, scopes: [...DEVICE_SCOPES], epoch: 1 });
|
||||
expect(verifyDeviceToken(`${token}x`)).toBeNull();
|
||||
expect(verifyDeviceToken(issueDeviceToken({ deviceId: randomUUID(), workspaceId: WS, userId: USER, scopes: [...DEVICE_SCOPES], epoch: 1 }, -1))).toBeNull();
|
||||
expect(verifyDeviceToken(issueDeviceToken({ deviceId: "bad", workspaceId: WS, userId: USER, scopes: [...DEVICE_SCOPES], epoch: 1 }))).toBeNull();
|
||||
});
|
||||
it("auth checks device epoch, current membership/scopes, and the workspace role epoch", async () => {
|
||||
const deviceId = randomUUID(), token = issueDeviceToken({ deviceId, workspaceId: WS, userId: USER, scopes: [...DEVICE_SCOPES], epoch: 1 });
|
||||
let deviceEpoch = 1, workspaceEpoch = 7, active = true;
|
||||
const client = { query: async (sql: string) => ({ rows: sql.includes("FROM memberships") ? (active ? [{ role: "Operator", authz_epoch: workspaceEpoch }] : []) : sql.includes("FROM devices") ? [{ epoch: deviceEpoch, scopes: [...DEVICE_SCOPES], authz_epoch: 7 }] : [] }) } as unknown as PoolClient;
|
||||
expect(await authorizeChat(client, token, WS, "livechat.view")).toMatchObject({ userId: USER });
|
||||
deviceEpoch += 1; expect(await authorizeChat(client, token, WS, "livechat.view")).toBeNull();
|
||||
deviceEpoch = 1; workspaceEpoch += 1; expect(await authorizeChat(client, token, WS, "livechat.view")).toBeNull();
|
||||
workspaceEpoch = 7; active = false; expect(await authorizeChat(client, token, WS, "livechat.view")).toBeNull();
|
||||
});
|
||||
});
|
||||
180
apps/api/src/live-chat/devices.ts
Normal file
180
apps/api/src/live-chat/devices.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Device pairing (§3.4): the web UI mints a single-use, TTL'd code; the app
|
||||
* redeems it for an epoch-gated device token bound to (workspace, user).
|
||||
*
|
||||
* Tokens use a separate HMAC domain and current device/workspace epochs fence
|
||||
* every REST request and active socket. Pairing codes expire after five minutes.
|
||||
*/
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { Injectable, Inject, Optional, UnauthorizedException, NotFoundException, ConflictException } from "@nestjs/common";
|
||||
import type { PoolClient } from "pg";
|
||||
import type { DeviceTokenPayload } from "@tactical-media-broadcast-hub/core";
|
||||
import { idempotentChat } from "./idempotency.js";
|
||||
import { pool, withTenant } from "../db/client.js";
|
||||
import { isUuid } from "../http/validation.js";
|
||||
import { DEVICE_SCOPES } from "@tactical-media-broadcast-hub/core";
|
||||
|
||||
export const DEVICE_DEPENDENCIES = Symbol("DEVICE_DEPENDENCIES");
|
||||
|
||||
export interface DeviceTransaction {
|
||||
<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export interface DeviceDependencies {
|
||||
transaction: DeviceTransaction;
|
||||
now?: () => Date;
|
||||
/** TTL of an unredeemed pairing code, ms (default 5 min, §3.4). */
|
||||
pairingTtlMs?: number;
|
||||
/** Lifetime of a device token, seconds (default 30 days). */
|
||||
tokenTtlSeconds?: number;
|
||||
/** Base URL the QR payload points at. */
|
||||
serverUrl?: string;
|
||||
lookupWorkspace?: (code: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface PairingIssued {
|
||||
pairingCode: string;
|
||||
qrPayload: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface RedeemResult {
|
||||
deviceToken: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface PairingRow {
|
||||
id: string; workspace_id: string; user_id: string; code: string; qr_payload: string;
|
||||
scopes: string[]; expires_at: Date; redeemed_at: Date | null; device_id: string | null;
|
||||
}
|
||||
|
||||
function pairingCode(): string {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const group = (): string => Array.from(randomBytes(4), b => alphabet[b % alphabet.length]).join("");
|
||||
return `${group()}-${group()}`;
|
||||
}
|
||||
|
||||
function deviceSecret(): string {
|
||||
const s = process.env.SESSION_SECRET;
|
||||
if (s !== undefined && s !== "") return s;
|
||||
if (process.env.NODE_ENV === "production") throw new Error("SESSION_SECRET required in production");
|
||||
return "dev-session-secret";
|
||||
}
|
||||
|
||||
function sign(body: string): string {
|
||||
return createHmac("sha256", `${deviceSecret()}:device-v1`).update(body).digest("base64url");
|
||||
}
|
||||
|
||||
export function issueDeviceToken(payload: Omit<DeviceTokenPayload, "issuedAt" | "expiresAt">, ttlSeconds = 30 * 24 * 3600): string {
|
||||
const full: DeviceTokenPayload = { ...payload, issuedAt: Math.floor(Date.now() / 1000), expiresAt: Math.floor(Date.now() / 1000) + ttlSeconds };
|
||||
const body = Buffer.from(JSON.stringify(full)).toString("base64url");
|
||||
return `${body}.${sign(body)}`;
|
||||
}
|
||||
|
||||
export function verifyDeviceToken(token: string): DeviceTokenPayload | null {
|
||||
const dot = token.lastIndexOf(".");
|
||||
if (dot === -1) return null;
|
||||
const body = token.slice(0, dot);
|
||||
const mac = token.slice(dot + 1);
|
||||
const expected = sign(body);
|
||||
if (Buffer.byteLength(mac) !== Buffer.byteLength(expected) || !timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf-8")) as DeviceTokenPayload;
|
||||
if (!isUuid(payload.deviceId) || !isUuid(payload.workspaceId) || !isUuid(payload.userId) ||
|
||||
!Number.isSafeInteger(payload.epoch) || payload.epoch < 1 || !Number.isSafeInteger(payload.expiresAt) ||
|
||||
payload.expiresAt <= Math.floor(Date.now() / 1000) || !Array.isArray(payload.scopes) ||
|
||||
!payload.scopes.every(scope => typeof scope === "string" && DEVICE_SCOPES.some(allowed => allowed === scope))) return null;
|
||||
return payload;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DeviceService {
|
||||
private readonly deps: Required<Omit<DeviceDependencies, "now">> & { now: () => Date };
|
||||
constructor(@Optional() @Inject(DEVICE_DEPENDENCIES) dependencies?: DeviceDependencies) {
|
||||
const now = dependencies?.now ?? (() => new Date());
|
||||
this.deps = {
|
||||
transaction: dependencies?.transaction ?? withTenant,
|
||||
pairingTtlMs: dependencies?.pairingTtlMs ?? 5 * 60 * 1000,
|
||||
tokenTtlSeconds: dependencies?.tokenTtlSeconds ?? 30 * 24 * 3600,
|
||||
serverUrl: dependencies?.serverUrl ?? process.env.PUBLIC_ORIGIN ?? "",
|
||||
lookupWorkspace: dependencies?.lookupWorkspace ?? (async code => {
|
||||
const lookup = await pool.query<{ workspace_id: string }>("SELECT device_pairing_workspace($1) AS workspace_id", [code]);
|
||||
return lookup.rows[0]?.workspace_id ?? null;
|
||||
}),
|
||||
now,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mint a pairing code for (workspace, user). TTL 5 minutes, single use. */
|
||||
async issuePairing(workspaceId: string, userId: string, scopes: readonly string[] = DEVICE_SCOPES): Promise<PairingIssued> {
|
||||
if (!scopes.every(scope => DEVICE_SCOPES.some(allowed => allowed === scope))) throw new UnauthorizedException("invalid device scopes");
|
||||
const code = pairingCode();
|
||||
const expiresAt = new Date(this.deps.now().getTime() + this.deps.pairingTtlMs);
|
||||
const qrPayload = JSON.stringify({ serverUrl: this.deps.serverUrl, pairingCode: code });
|
||||
await this.deps.transaction(workspaceId, async client => {
|
||||
await client.query(
|
||||
`INSERT INTO device_pairings (workspace_id, user_id, code, qr_payload, expires_at, scopes) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[workspaceId, userId, code, qrPayload, expiresAt, [...scopes]]);
|
||||
});
|
||||
return { pairingCode: code, qrPayload, expiresAt: expiresAt.toISOString() };
|
||||
}
|
||||
|
||||
async redeemCode(code: string, deviceName: string, key: string | undefined, expectedWorkspace?: string): Promise<RedeemResult> {
|
||||
if (!/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code)) throw new UnauthorizedException("invalid pairing code");
|
||||
const workspaceId = await this.deps.lookupWorkspace(code);
|
||||
if (!workspaceId || (expectedWorkspace !== undefined && expectedWorkspace !== workspaceId)) throw new UnauthorizedException("invalid pairing code");
|
||||
const owner = await this.deps.transaction(workspaceId, async client => {
|
||||
const result = await client.query<{ user_id: string }>("SELECT user_id FROM device_pairings WHERE workspace_id=$1 AND code=$2", [workspaceId, code]);
|
||||
return result.rows[0]?.user_id;
|
||||
});
|
||||
if (!owner) throw new UnauthorizedException("invalid pairing code");
|
||||
return idempotentChat(this.deps.transaction, workspaceId, owner, `redeem:${createHash("sha256").update(code).digest("hex")}`, key, { deviceName }, () => this.redeem(workspaceId, code, deviceName));
|
||||
}
|
||||
|
||||
/** Redeem within an explicit tenant, rejecting codes from other workspaces. */
|
||||
async redeem(workspaceId: string, code: string, deviceName: string): Promise<RedeemResult> {
|
||||
if (!/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code)) throw new UnauthorizedException("invalid pairing code");
|
||||
return this.deps.transaction(workspaceId, async client => {
|
||||
const found = await client.query<PairingRow>(
|
||||
`SELECT * FROM device_pairings WHERE workspace_id=$1 AND code=$2 FOR UPDATE`, [workspaceId, code]);
|
||||
const row = found.rows[0];
|
||||
if (!row) throw new UnauthorizedException("invalid pairing code");
|
||||
if (row.redeemed_at !== null) throw new ConflictException("pairing code already redeemed");
|
||||
if (row.expires_at.getTime() <= this.deps.now().getTime()) throw new UnauthorizedException("pairing code expired");
|
||||
const device = await client.query<{ id: string }>(
|
||||
`INSERT INTO devices (workspace_id, user_id, name, scopes, authz_epoch) SELECT $1,$2,$3,$4,authz_epoch FROM workspaces WHERE id=$1 RETURNING id`,
|
||||
[workspaceId, row.user_id, deviceName, row.scopes]);
|
||||
const deviceId = device.rows[0]?.id;
|
||||
if (!deviceId) throw new UnauthorizedException("pairing workspace no longer exists");
|
||||
await client.query(`UPDATE device_pairings SET redeemed_at = now(), device_id = $3 WHERE workspace_id=$1 AND code=$2`,
|
||||
[workspaceId, code, deviceId]);
|
||||
const token = issueDeviceToken({ deviceId, workspaceId, userId: row.user_id, scopes: row.scopes, epoch: 1 }, this.deps.tokenTtlSeconds);
|
||||
return { deviceToken: token, workspaceId };
|
||||
});
|
||||
}
|
||||
|
||||
/** Revoke: bumps the epoch, so every outstanding token fails closed. */
|
||||
async revoke(workspaceId: string, deviceId: string): Promise<void> {
|
||||
const result = await this.deps.transaction(workspaceId, async client => {
|
||||
const updated = await client.query<{ epoch: string | number }>(
|
||||
`UPDATE devices SET revoked_at = now(), epoch = epoch + 1 WHERE workspace_id=$1 AND id=$2 AND revoked_at IS NULL RETURNING epoch`,
|
||||
[workspaceId, deviceId]);
|
||||
return updated.rowCount ?? 0;
|
||||
});
|
||||
if (result === 0) throw new NotFoundException("device not found or already revoked");
|
||||
}
|
||||
|
||||
/** Epoch check for a presented device token. */
|
||||
async epochOf(workspaceId: string, deviceId: string): Promise<number | null> {
|
||||
return this.deps.transaction(workspaceId, async client => {
|
||||
const row = await client.query<{ epoch: string | number; revoked_at: Date | null }>(
|
||||
`SELECT epoch, revoked_at FROM devices WHERE workspace_id=$1 AND id=$2`, [workspaceId, deviceId]);
|
||||
const found = row.rows[0];
|
||||
if (!found || found.revoked_at !== null) return null;
|
||||
return Number(found.epoch);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceTokenFingerprint = (token: string): string => createHash("sha256").update(token).digest("hex").slice(0, 16);
|
||||
102
apps/api/src/live-chat/governors.ts
Normal file
102
apps/api/src/live-chat/governors.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Send governors (§3.2): per-provider token buckets in front of every
|
||||
* outbound chat call, plus a YouTube-style daily quota ledger.
|
||||
*
|
||||
* A send below budget is REFUSED with a paused reason — it never queues and
|
||||
* never throws, so a fan-out keeps going for the targets that still have
|
||||
* budget (§3.5 "an incapable target is refused with its reason").
|
||||
*/
|
||||
export interface GovernorVerdict {
|
||||
allowed: boolean;
|
||||
/** Present only when refused. */
|
||||
reason?: string;
|
||||
state?: "ok" | "paused:rate" | "paused:quota" | "degraded";
|
||||
}
|
||||
|
||||
export interface TokenBucketOptions {
|
||||
/** Burst capacity (tokens). */
|
||||
capacity: number;
|
||||
/** Refill per second. */
|
||||
refillPerSecond: number;
|
||||
/** Virtual clock, overridable in tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Classic token bucket: `capacity` burst, continuous refill. */
|
||||
export class TokenBucket {
|
||||
private tokens: number;
|
||||
private last: number;
|
||||
constructor(private readonly options: TokenBucketOptions) {
|
||||
if (!Number.isInteger(options.capacity) || options.capacity < 1) throw new Error("capacity must be a positive integer");
|
||||
if (!(options.refillPerSecond > 0)) throw new Error("refillPerSecond must be positive");
|
||||
this.tokens = options.capacity;
|
||||
this.last = options.now?.() ?? Date.now();
|
||||
}
|
||||
/** Peek without consuming. */
|
||||
available(): number { this.refill(); return this.tokens; }
|
||||
/** Try to consume one token. */
|
||||
tryTake(): boolean { this.refill(); if (this.tokens < 1) return false; this.tokens -= 1; return true; }
|
||||
private refill(): void {
|
||||
const now = this.options.now?.() ?? Date.now();
|
||||
const elapsed = (now - this.last) / 1000;
|
||||
if (elapsed > 0) { this.tokens = Math.min(this.options.capacity, this.tokens + elapsed * this.options.refillPerSecond); this.last = now; }
|
||||
}
|
||||
}
|
||||
|
||||
/** Daily unit ledger (YouTube quota: list=1, insert/delete/ban=50, 10 000/day). */
|
||||
export class QuotaLedger {
|
||||
private spent = 0;
|
||||
private day: string;
|
||||
constructor(private readonly dailyBudget: number, private readonly costPerCall = 1, private readonly now: () => number = Date.now) {
|
||||
this.day = this.currentDay();
|
||||
if (!Number.isFinite(dailyBudget) || dailyBudget < 0 || !Number.isFinite(costPerCall) || costPerCall < 0) throw new Error("invalid quota budget/cost");
|
||||
}
|
||||
private currentDay(): string { return new Date(this.now()).toISOString().slice(0, 10); }
|
||||
private reset(): void { const day = this.currentDay(); if (day !== this.day) { this.spent = 0; this.day = day; } }
|
||||
get used(): number { this.reset(); return this.spent; }
|
||||
get budget(): number { return this.dailyBudget; }
|
||||
canAfford(cost = this.costPerCall): boolean { this.reset(); return Number.isFinite(cost) && cost >= 0 && this.spent + cost <= this.dailyBudget; }
|
||||
/** Record a spend; returns false (and records nothing) when over budget. */
|
||||
charge(cost = this.costPerCall): boolean {
|
||||
if (!this.canAfford(cost)) return false;
|
||||
this.spent += cost;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SendGovernorOptions {
|
||||
/** Tokens per provider key, e.g. `twitch`, `youtube`, `mock-livestream`. */
|
||||
buckets?: Record<string, TokenBucketOptions>;
|
||||
/** Daily unit budgets per provider key (quota ledger). */
|
||||
quotas?: Record<string, { budget: number; costPerCall?: number }>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Per-provider send governor: token bucket + optional daily quota. */
|
||||
export class SendGovernors {
|
||||
private readonly buckets = new Map<string, TokenBucket>();
|
||||
private readonly quotas = new Map<string, QuotaLedger>();
|
||||
constructor(options: SendGovernorOptions = {}) {
|
||||
for (const [key, bucket] of Object.entries(options.buckets ?? {})) this.buckets.set(key, new TokenBucket({ ...bucket, now: bucket.now ?? options.now }));
|
||||
for (const [key, quota] of Object.entries(options.quotas ?? {})) this.quotas.set(key, new QuotaLedger(quota.budget, quota.costPerCall ?? 1, options.now));
|
||||
}
|
||||
/** Probe a provider's outbound capacity without consuming anything. */
|
||||
probe(provider: string): GovernorVerdict {
|
||||
const quota = this.quotas.get(provider);
|
||||
if (quota && !quota.canAfford()) return { allowed: false, reason: "daily quota exhausted", state: "paused:quota" };
|
||||
const bucket = this.buckets.get(provider);
|
||||
if (bucket && bucket.available() < 1) return { allowed: false, reason: "send rate budget exhausted", state: "paused:rate" };
|
||||
return { allowed: true, state: "ok" };
|
||||
}
|
||||
/** Consume one send's worth of budget for `provider`. */
|
||||
take(provider: string, cost?: number): GovernorVerdict {
|
||||
const quota = this.quotas.get(provider);
|
||||
if (quota && !quota.canAfford(cost)) return { allowed: false, reason: "daily quota exhausted", state: "paused:quota" };
|
||||
const bucket = this.buckets.get(provider);
|
||||
if (bucket && !bucket.tryTake()) return { allowed: false, reason: "send rate budget exhausted", state: "paused:rate" };
|
||||
if (quota) quota.charge(cost);
|
||||
return { allowed: true, state: "ok" };
|
||||
}
|
||||
/** A provider with no configured governor is unlimited (mock, local hubs). */
|
||||
configured(provider: string): boolean { return this.buckets.has(provider) || this.quotas.has(provider); }
|
||||
}
|
||||
439
apps/api/src/live-chat/hub.test.ts
Normal file
439
apps/api/src/live-chat/hub.test.ts
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import type { PoolClient } from "pg";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ChatRingBuffer } from "./ring-buffer.js";
|
||||
import { SendGovernors } from "./governors.js";
|
||||
import { Duplex } from "node:stream";
|
||||
import type { Socket } from "node:net";
|
||||
import { createMockLivestreamConnector } from "@tactical-media-broadcast-hub/connector-mock-livestream";
|
||||
import { RealtimeService } from "../realtime/realtime.service.js";
|
||||
import { RealtimeSocket } from "../realtime/websocket.js";
|
||||
import { RespRedisSubscriptions } from "../realtime/subscribe.js";
|
||||
import { encode, parse, type ControlRedis } from "../media-control/redis.js";
|
||||
import { MediaControlService } from "../media-control/media-control.service.js";
|
||||
import { ChatRealtimeBridge, CHAT_PUBLISH_SCRIPT } from "./bridge.js";
|
||||
import { MockLivestreamDriver } from "./mock-driver.js";
|
||||
import { issueSession } from "../auth/session.js";
|
||||
import { LiveChatHubService, type LiveChatPublish } from "./live-chat.service.js";
|
||||
import type { ChatTarget, ChatTransport, NormalizedChatEvent } from "./types.js";
|
||||
|
||||
const WS = randomUUID(), CH = randomUUID();
|
||||
|
||||
/**
|
||||
* SQL-semantics fake for the hub's three statements:
|
||||
* - live_chat_seq: FOR UPDATE serializes per (workspace, channel); the
|
||||
* create-or-lock pattern runs exactly as written in the service.
|
||||
* - live_chat_messages: UNIQUE(workspace_id, provider, provider_msg_id)
|
||||
* turns a replay into a no-op that returns nothing.
|
||||
* - cursor paging: seq > after ORDER BY seq ASC LIMIT n.
|
||||
* The fake implements the CONSTRAINTS, not the text — the live-DB variant of
|
||||
* these tests is live-chat.integration.test.ts.
|
||||
*/
|
||||
class SqlFake {
|
||||
readonly messages: Array<Record<string, unknown> & { workspace_id: string; channel_id: string; seq: number; provider: string; provider_msg_id: string }> = [];
|
||||
readonly seen = new Set<string>();
|
||||
readonly seqRows = new Map<string, number>();
|
||||
readonly locks = new Map<string, Promise<unknown>>();
|
||||
readonly moderated: string[] = [];
|
||||
|
||||
async transaction<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const key = workspaceId; // one lock per workspace fakes the row lock
|
||||
const prior = [...this.locks.values()];
|
||||
let unlock: () => void = () => undefined;
|
||||
const gate = new Promise<void>(resolve => { unlock = resolve; });
|
||||
this.locks.set(key, gate);
|
||||
await Promise.all(prior);
|
||||
try { return await fn(new Client(this) as unknown as PoolClient); } finally { unlock(); this.locks.delete(key); }
|
||||
}
|
||||
}
|
||||
|
||||
class Client {
|
||||
constructor(private readonly db: SqlFake) {}
|
||||
async query(sql: string, params: unknown[]): Promise<{ rows: Array<Record<string, unknown>>; rowCount: number }> {
|
||||
if (sql.includes("INSERT INTO live_chat_seen_events")) {
|
||||
const key = JSON.stringify(params);
|
||||
if (this.db.seen.has(key)) return { rows: [], rowCount: 0 };
|
||||
this.db.seen.add(key); return { rows: [{ provider_event_id: params[2] }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes("live_chat_seq") && sql.includes("INSERT")) {
|
||||
const ws = String(params[0]), ch = String(params[1]);
|
||||
if (!this.db.seqRows.has(`${ws}:${ch}`)) this.db.seqRows.set(`${ws}:${ch}`, 0);
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (sql.includes("live_chat_seq") && sql.includes("FOR UPDATE")) {
|
||||
const ws = String(params[0]), ch = String(params[1]);
|
||||
return { rows: [{ seq: this.db.seqRows.get(`${ws}:${ch}`) ?? 0 }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes("live_chat_seq")) { // the GREATEST update
|
||||
const ws = String(params[0]), ch = String(params[1]), seq = Number(params[2]);
|
||||
const key = `${ws}:${ch}`;
|
||||
this.db.seqRows.set(key, Math.max(this.db.seqRows.get(key) ?? 0, seq));
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes("INSERT INTO live_chat_messages")) {
|
||||
const columns = Array.from({ length: 16 }, (_, i) => params[i] as unknown[]);
|
||||
const inserted: Array<Record<string, unknown>> = [];
|
||||
for (let i = 0; i < (columns[0]?.length ?? 0); i += 1) {
|
||||
const row = {
|
||||
id: randomUUID(), workspace_id: String(columns[0]?.[i]), channel_id: String(columns[1]?.[i]), connection_id: columns[2]?.[i],
|
||||
provider: String(columns[3]?.[i]), provider_msg_id: String(columns[4]?.[i]), author_handle: columns[5]?.[i],
|
||||
author_display_name: columns[6]?.[i], author_avatar_url: columns[7]?.[i], author_badges: JSON.parse(String(columns[8]?.[i])) as unknown,
|
||||
author_color: columns[9]?.[i], body: columns[10]?.[i], reply_to: columns[11]?.[i], seq: Number(columns[12]?.[i]),
|
||||
sent_at: columns[13]?.[i], received_at: columns[14]?.[i], source: columns[15]?.[i], is_moderated: false,
|
||||
};
|
||||
const dup = this.db.messages.some(m => m.workspace_id === row.workspace_id && m.provider === row.provider && m.provider_msg_id === row.provider_msg_id);
|
||||
if (!dup) { this.db.messages.push(row); inserted.push(row); }
|
||||
}
|
||||
return { rows: inserted, rowCount: inserted.length };
|
||||
}
|
||||
if (sql.includes("SELECT") && sql.includes("live_chat_messages") && sql.includes("provider=$5")) {
|
||||
const items = this.db.messages.filter(m => m.workspace_id === params[0] && m.channel_id === params[1] && Number(m.seq) > Number(params[2]) && m.provider === params[4])
|
||||
.sort((a, b) => a.seq - b.seq).slice(0, Number(params[3]));
|
||||
return { rows: items, rowCount: items.length };
|
||||
}
|
||||
if (sql.includes("SELECT") && sql.includes("live_chat_messages")) {
|
||||
const after = sql.includes("id=$3")
|
||||
? this.db.messages.filter(m => m.workspace_id === params[0] && m.channel_id === params[1] && m.id === params[2])
|
||||
: this.db.messages.filter(m => m.workspace_id === params[0] && m.channel_id === params[1] && Number(m.seq) > Number(params[2]))
|
||||
.sort((a, b) => a.seq - b.seq).slice(0, Number(params[3]));
|
||||
return { rows: after, rowCount: after.length };
|
||||
}
|
||||
if (sql.includes("UPDATE live_chat_messages")) {
|
||||
const [ws, , provider, providerMsgId] = params;
|
||||
let count = 0;
|
||||
for (const m of this.db.messages) {
|
||||
if (m.workspace_id === ws && m.provider === provider && m.provider_msg_id === providerMsgId) { m.is_moderated = true; count += 1; }
|
||||
}
|
||||
return { rows: [], rowCount: count };
|
||||
}
|
||||
throw new Error(`SqlFake: unexpected statement ${sql.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(options?: {
|
||||
targets?: ChatTarget[]; transports?: Record<string, ChatTransport>; governors?: SendGovernors; capacity?: number; publish?: LiveChatPublish;
|
||||
}) {
|
||||
const db = new SqlFake();
|
||||
const buffer = new ChatRingBuffer(options?.capacity ?? 500);
|
||||
const published: Array<{ workspaceId: string; channelId: string; frame: Record<string, unknown> }> = [];
|
||||
const transports = options?.transports ?? {};
|
||||
const targets = options?.targets ?? [];
|
||||
let clock = Date.parse("2026-10-07T10:00:00Z");
|
||||
const hub = new LiveChatHubService({
|
||||
transaction: (workspaceId, fn) => db.transaction(workspaceId, fn),
|
||||
publish: async (workspaceId, channelId, frame) => { published.push({ workspaceId, channelId, frame }); await options?.publish?.(workspaceId, channelId, frame); },
|
||||
buffer,
|
||||
governors: options?.governors ?? new SendGovernors(),
|
||||
targets: async () => targets,
|
||||
transports,
|
||||
now: () => new Date(clock += 7),
|
||||
});
|
||||
return { db, buffer, published, hub, transports, targets };
|
||||
}
|
||||
|
||||
function event(overrides: Partial<NormalizedChatEvent> = {}): NormalizedChatEvent {
|
||||
return {
|
||||
provider: "mock-livestream", connectionId: "conn-1", channelId: CH, providerMessageId: `pm-${randomUUID().slice(0, 8)}`,
|
||||
author: { handle: "viewer@example" }, text: "hello", sentAt: new Date("2026-10-07T10:00:00Z"), source: "live-chat", ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function target(overrides: Partial<ChatTarget> = {}): ChatTarget {
|
||||
return { connectionId: "conn-1", provider: "mock-livestream", liveRemoteId: "live-1", read: true, reply: true, moderate: true, state: "ok", ...overrides };
|
||||
}
|
||||
|
||||
describe("ingestion: seq assignment, dedupe, fan-out", () => {
|
||||
it("assigns monotonic per-channel seqs, stores rows, publishes one frame per insert", async () => {
|
||||
const f = fixture();
|
||||
const first = await f.hub.ingest(WS, [event({ text: "one" }), event({ text: "two" })]);
|
||||
expect(first.inserted.map(m => m.seq)).toEqual([1, 2]);
|
||||
expect(first.inserted.map(m => m.text)).toEqual(["one", "two"]);
|
||||
expect(first.duplicates).toEqual([]);
|
||||
expect(f.published).toHaveLength(2);
|
||||
expect(f.published[0]?.frame).toMatchObject({ kind: "chat", seq: 1 });
|
||||
const second = await f.hub.ingest(WS, [event({ text: "three" })]);
|
||||
expect(second.inserted[0]?.seq).toBe(3); // continues the channel sequence
|
||||
});
|
||||
|
||||
it("same provider id ingested twice = one row, one frame, and the replay is reported", async () => {
|
||||
const f = fixture();
|
||||
const original = event({ providerMessageId: "pm-dup" });
|
||||
const first = await f.hub.ingest(WS, [original]);
|
||||
const replay = await f.hub.ingest(WS, [original]);
|
||||
expect(first.inserted).toHaveLength(1);
|
||||
expect(replay.inserted).toHaveLength(0);
|
||||
expect(replay.duplicates).toEqual(["pm-dup"]);
|
||||
expect(f.db.messages).toHaveLength(1); // one durable row
|
||||
expect(f.published).toHaveLength(1); // replay publishes nothing
|
||||
expect(f.buffer.latest(CH)).toHaveLength(1); // and does not enter the window
|
||||
});
|
||||
|
||||
it("dedupe is scoped to (workspace, provider): same id, different provider, both stored", async () => {
|
||||
const f = fixture();
|
||||
await f.hub.ingest(WS, [event({ provider: "twitch", providerMessageId: "shared", connectionId: "conn-tw" })]);
|
||||
const second = await f.hub.ingest(WS, [event({ provider: "youtube", providerMessageId: "shared", connectionId: "conn-yt" })]);
|
||||
expect(second.inserted).toHaveLength(1);
|
||||
expect(f.db.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("cursor paging returns strictly-after rows in seq order and a next cursor", async () => {
|
||||
const f = fixture();
|
||||
await f.hub.ingest(WS, [event(), event(), event(), event(), event()]);
|
||||
const page1 = await f.hub.messages(WS, CH, { limit: 3 });
|
||||
expect(page1.items.map(m => m.seq)).toEqual([1, 2, 3]);
|
||||
expect(page1.nextCursor).toBe(3);
|
||||
const page2 = await f.hub.messages(WS, CH, { after: page1.nextCursor, limit: 3 });
|
||||
expect(page2.items.map(m => m.seq)).toEqual([4, 5]);
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resume serves from the window while lastSeq is inside it, resync past its edge", async () => {
|
||||
const f = fixture();
|
||||
for (let i = 0; i < 5; i += 1) await f.hub.ingest(WS, [event()]);
|
||||
const inside = f.hub.resume(CH, 3);
|
||||
expect(inside.resync).toBe(false);
|
||||
expect(inside.messages.map(m => m.seq)).toEqual([4, 5]);
|
||||
expect(f.hub.resume(CH, 0).messages).toHaveLength(5);
|
||||
const small = fixture({ capacity: 4 });
|
||||
for (let i = 0; i < 6; i += 1) await small.hub.ingest(WS, [event()]);
|
||||
expect(small.hub.resume(CH, 1).resync).toBe(true);
|
||||
expect(small.hub.resume(CH, 2).messages.map(m => m.seq)).toEqual([3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system gap notices", () => {
|
||||
it("stores one system notice and emits a distinct chat.gap sequence, deduping repeated gaps", async () => {
|
||||
const f = fixture(), gap = event({ providerMessageId: "disconnect-1" });
|
||||
await f.hub.gap(WS, gap, "provider disconnected");
|
||||
expect(f.db.messages[0]?.source).toBe("system");
|
||||
expect(f.published.at(-1)?.frame).toMatchObject({ seq: 2, provider: "mock-livestream", reason: "provider disconnected" });
|
||||
await f.hub.gap(WS, gap, "provider disconnected"); expect(f.published).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reply fan-out with per-target Delivery rows", () => {
|
||||
const transportsFor = (send: ChatTransport["send"]): Record<string, ChatTransport> => ({
|
||||
"mock-livestream": { provider: "mock-livestream", send, moderate: async () => undefined },
|
||||
});
|
||||
|
||||
it("fans out to every capable target with one Delivery row each and stores the echo", async () => {
|
||||
const send = vi.fn(async () => ({ providerMessageId: "pm-ours", sentAt: new Date("2026-10-07T10:01:00Z") }));
|
||||
const f = fixture({
|
||||
targets: [target({ connectionId: "conn-a" }), target({ connectionId: "conn-b", provider: "twitch", liveRemoteId: "tw-1" })],
|
||||
transports: { ...transportsFor(send), twitch: { provider: "twitch", send, moderate: async () => undefined } },
|
||||
});
|
||||
const outcome = await f.hub.send(WS, CH, { text: "hello from the studio", targets: "all", author: { handle: "studio" } });
|
||||
expect(outcome.delivery).toHaveLength(2);
|
||||
expect(outcome.delivery.every(d => d.status === "sent")).toBe(true);
|
||||
expect(outcome.message?.text).toBe("hello from the studio");
|
||||
expect(outcome.message?.providerMessageId).toBe("pm-ours");
|
||||
expect(f.published.some(p => p.frame.delivery !== undefined)).toBe(true); // chat.delivery frame
|
||||
});
|
||||
|
||||
it("an unsupported target never fails the batch (§3.5)", async () => {
|
||||
const send = vi.fn(async () => ({ providerMessageId: "pm-ok", sentAt: new Date() }));
|
||||
const f = fixture({
|
||||
targets: [
|
||||
target({ connectionId: "conn-ok" }),
|
||||
target({ connectionId: "conn-no-reply", reply: false, reason: "live chat write not supported" }),
|
||||
],
|
||||
transports: transportsFor(send as ChatTransport["send"]),
|
||||
});
|
||||
const outcome = await f.hub.send(WS, CH, { text: "hi", targets: "all", author: { handle: "studio" } });
|
||||
expect(outcome.delivery).toHaveLength(2);
|
||||
expect(outcome.delivery.find(d => d.connectionId === "conn-ok")).toMatchObject({ status: "sent" });
|
||||
expect(outcome.delivery.find(d => d.connectionId === "conn-no-reply")).toMatchObject({ status: "unsupported", error: "live chat write not supported" });
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(outcome.message).not.toBeNull();
|
||||
});
|
||||
|
||||
it("a transport failure is a failed Delivery row; the other target still sends", async () => {
|
||||
const send = vi.fn(async (request: { connectionId: string }) => {
|
||||
if (request.connectionId === "conn-bad") throw new Error("Helix 503");
|
||||
return { providerMessageId: "pm-ok", sentAt: new Date() };
|
||||
});
|
||||
const f = fixture({
|
||||
targets: [target({ connectionId: "conn-good" }), target({ connectionId: "conn-bad" })],
|
||||
transports: transportsFor(send as ChatTransport["send"]),
|
||||
});
|
||||
const outcome = await f.hub.send(WS, CH, { text: "hi", targets: "all", author: { handle: "studio" } });
|
||||
expect(outcome.delivery.find(d => d.connectionId === "conn-bad")).toMatchObject({ status: "failed", error: "Helix 503" });
|
||||
expect(outcome.delivery.find(d => d.connectionId === "conn-good")).toMatchObject({ status: "sent" });
|
||||
});
|
||||
|
||||
it("a reply below budget is REFUSED with a paused reason and nothing is sent", async () => {
|
||||
const send = vi.fn(async () => ({ providerMessageId: "pm-x", sentAt: new Date() }));
|
||||
const f = fixture({
|
||||
targets: [target()],
|
||||
transports: transportsFor(send as ChatTransport["send"]),
|
||||
governors: new SendGovernors({ quotas: { "mock-livestream": { budget: 1, costPerCall: 2 } } }), // first take already over budget
|
||||
});
|
||||
const outcome = await f.hub.send(WS, CH, { text: "hi", targets: "all", author: { handle: "studio" } });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(outcome.message).toBeNull();
|
||||
expect(outcome.refused).toMatchObject({ state: "paused:quota", reason: expect.stringContaining("quota") });
|
||||
expect(outcome.delivery[0]).toMatchObject({ status: "unsupported", error: expect.stringContaining("quota") });
|
||||
expect(f.db.messages).toHaveLength(0); // refused means no echo row either
|
||||
});
|
||||
|
||||
it("target selection honours an explicit target list", async () => {
|
||||
const send = vi.fn(async (_request: Parameters<ChatTransport["send"]>[0]) => ({ providerMessageId: "pm-1", sentAt: new Date() }));
|
||||
const f = fixture({
|
||||
targets: [target({ connectionId: "conn-a" }), target({ connectionId: "conn-b" })],
|
||||
transports: transportsFor(send as ChatTransport["send"]),
|
||||
});
|
||||
await f.hub.send(WS, CH, { text: "hi", targets: ["conn-b"], author: { handle: "studio" } });
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.calls[0]?.[0]?.connectionId).toBe("conn-b");
|
||||
expect(outcomeOf(f)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("resolves a hub replyTo id to the provider parent id before sending", async () => {
|
||||
const send = vi.fn(async (_request: Parameters<ChatTransport["send"]>[0]) => ({ providerMessageId: "reply", sentAt: new Date() }));
|
||||
const f = fixture({ targets: [target()], transports: transportsFor(send) });
|
||||
const parent = (await f.hub.ingest(WS, [event({ providerMessageId: "parent-provider-id" })])).inserted[0];
|
||||
if (!parent) throw new Error("missing parent");
|
||||
await f.hub.send(WS, CH, { text: "threaded", targets: "all", replyTo: parent.id, author: { handle: "studio" } });
|
||||
expect(send).toHaveBeenCalledWith(expect.objectContaining({ replyTo: "parent-provider-id" }));
|
||||
});
|
||||
|
||||
it("no capable targets at all is a clean refusal, not an exception", async () => {
|
||||
const f = fixture({ targets: [] });
|
||||
const outcome = await f.hub.send(WS, CH, { text: "hi", targets: "all", author: { handle: "studio" } });
|
||||
expect(outcome.refused).toMatchObject({ state: "degraded", reason: "no capable target" });
|
||||
expect(outcome.delivery).toEqual([]);
|
||||
});
|
||||
|
||||
function outcomeOf(f: { published: Array<{ frame: Record<string, unknown> }> }): unknown[] {
|
||||
return f.published.filter(p => p.frame.delivery !== undefined).flatMap(p => p.frame.delivery as unknown[]);
|
||||
}
|
||||
});
|
||||
|
||||
describe("moderation fan-out", () => {
|
||||
it("delete fans out, flags the local row and publishes a chat.moderated frame", async () => {
|
||||
const moderate = vi.fn(async (_request: Parameters<ChatTransport["moderate"]>[0]) => undefined);
|
||||
const f = fixture({
|
||||
targets: [target(), target({ connectionId: "conn-no-mod", moderate: false })],
|
||||
transports: { "mock-livestream": { provider: "mock-livestream", send: async () => ({ providerMessageId: "x", sentAt: new Date() }), moderate: moderate as ChatTransport["moderate"] } },
|
||||
});
|
||||
const stored = (await f.hub.ingest(WS, [event({ providerMessageId: "pm-victim" })])).inserted[0];
|
||||
const outcome = await f.hub.moderate(WS, CH, {
|
||||
action: "delete",
|
||||
message: { provider: "mock-livestream", providerMessageId: "pm-victim", authorHandle: "viewer@example" },
|
||||
});
|
||||
expect(moderate).toHaveBeenCalledWith(expect.objectContaining({ messageRemoteId: "pm-victim", action: "delete" }));
|
||||
expect(outcome.delivery.find(d => d.connectionId === "conn-no-mod")?.status).toBe("unsupported");
|
||||
expect(outcome.delivery.find(d => d.connectionId === "conn-1")?.status).toBe("sent");
|
||||
expect(f.db.messages[0]?.is_moderated).toBe(true);
|
||||
const frame = f.published.at(-1)?.frame;
|
||||
expect(frame).toMatchObject({ kind: "chat", action: "delete", targetMessageId: "pm-victim" });
|
||||
void stored;
|
||||
});
|
||||
|
||||
it("a failed deletion preserves the row and emits no moderation success frame", async () => {
|
||||
const f = fixture({ targets: [target()], transports: { "mock-livestream": { provider: "mock-livestream", send: async () => ({ providerMessageId: "x", sentAt: new Date() }), moderate: async () => { throw new Error("provider refused"); } } } });
|
||||
await f.hub.ingest(WS, [event({ providerMessageId: "victim" })]);
|
||||
const outcome = await f.hub.moderate(WS, CH, { action: "delete", message: { provider: "mock-livestream", providerMessageId: "victim", authorHandle: "viewer" } });
|
||||
expect(outcome.delivery[0]?.status).toBe("failed"); expect(f.db.messages[0]?.is_moderated).toBe(false);
|
||||
expect(f.published).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("timeout passes durationSeconds to the transport", async () => {
|
||||
const moderate = vi.fn(async (_request: Parameters<ChatTransport["moderate"]>[0]) => undefined);
|
||||
const f = fixture({
|
||||
targets: [target()],
|
||||
transports: { "mock-livestream": { provider: "mock-livestream", send: async () => ({ providerMessageId: "x", sentAt: new Date() }), moderate: moderate as ChatTransport["moderate"] } },
|
||||
});
|
||||
await f.hub.moderate(WS, CH, { action: "timeout", durationSeconds: 600, message: { provider: "mock-livestream", providerMessageId: "pm-1", authorHandle: "a" } });
|
||||
expect(moderate.mock.calls[0]?.[0]).toMatchObject({ action: "timeout", durationSeconds: 600 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities endpoint shape", () => {
|
||||
it("mirrors target rows with state and optional reason", async () => {
|
||||
const f = fixture({ targets: [target(), target({ connectionId: "c2", reply: false, state: "paused:quota", reason: "daily quota exhausted" })] });
|
||||
const caps = await f.hub.capabilities(WS, CH);
|
||||
expect(caps).toEqual([
|
||||
expect.objectContaining({ connectionId: "conn-1", read: true, reply: true, moderate: true, state: "ok" }),
|
||||
expect.objectContaining({ connectionId: "c2", reply: false, state: "paused:quota", reason: "daily quota exhausted" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
class RespChatSocket extends Duplex {
|
||||
constructor() { super(); queueMicrotask(() => this.emit("connect")); }
|
||||
override _read(): void {}
|
||||
override _write(bytes: Buffer, _encoding: BufferEncoding, done: (error?: Error | null) => void): void {
|
||||
const parts = parse(bytes)?.reply;
|
||||
if (!Array.isArray(parts)) { done(new Error("invalid RESP")); return; }
|
||||
queueMicrotask(() => this.emit("data", encode([String(parts[0]).toLowerCase(), String(parts[1]), "1"])));
|
||||
done();
|
||||
}
|
||||
publish(topic: string, raw: string): void { const bytes = encode(["message", topic, raw]); this.emit("data", bytes.subarray(0, 7)); this.emit("data", bytes.subarray(7)); }
|
||||
}
|
||||
class ClientWire extends Duplex {
|
||||
readonly frames: Array<Record<string, unknown>> = [];
|
||||
override _read(): void {}
|
||||
override _write(bytes: Buffer, _encoding: BufferEncoding, done: (error?: Error | null) => void): void {
|
||||
if ((bytes[0] ?? 0) === 129) this.frames.push(JSON.parse(bytes.subarray((bytes[1] ?? 0) === 126 ? 4 : 2).toString()) as Record<string, unknown>);
|
||||
done();
|
||||
}
|
||||
send(value: unknown): void {
|
||||
const body = Buffer.from(JSON.stringify(value)), header = Buffer.alloc(body.length < 126 ? 6 : 8);
|
||||
header[0] = 129;
|
||||
if (body.length < 126) header[1] = 128 | body.length;
|
||||
else { header[1] = 128 | 126; header.writeUInt16BE(body.length, 2); }
|
||||
// A zero mask is valid; fragmentation exercises the incremental decoder.
|
||||
const bytes = Buffer.concat([header, body]); this.emit("data", bytes.subarray(0, 5)); this.emit("data", bytes.subarray(5));
|
||||
}
|
||||
}
|
||||
async function settle(): Promise<void> { await new Promise(resolve => setTimeout(resolve, 10)); }
|
||||
|
||||
describe("mock connector → hub rows → Redis RESP → WS chat contract", () => {
|
||||
it("dedupes, preserves ingress seq, resumes on a fresh gateway, and resyncs after 500-frame eviction", async () => {
|
||||
let respSocket: RespChatSocket | null = null;
|
||||
const subscriptions = new RespRedisSubscriptions("redis://localhost", () => undefined, () => { respSocket = new RespChatSocket(); return respSocket as unknown as Socket; });
|
||||
const window: Record<string, unknown>[] = [];
|
||||
const redis: ControlRedis = {
|
||||
get: async () => null, mget: async () => [], set: async () => true, incr: async () => 1, sadd: async () => undefined, smembers: async () => [], publish: async () => 1, close: () => undefined,
|
||||
eval: async (script, keys, args) => {
|
||||
expect(script).toBe(CHAT_PUBLISH_SCRIPT);
|
||||
const frame = JSON.parse(args[1] ?? "{}") as Record<string, unknown>;
|
||||
window.push(frame); if (window.length > 500) window.shift();
|
||||
respSocket?.publish(keys[1] ?? "", args[1] ?? ""); return 1;
|
||||
},
|
||||
};
|
||||
const bridge = new ChatRealtimeBridge(undefined, redis), f = fixture({ publish: bridge.publish });
|
||||
const connector = createMockLivestreamConnector();
|
||||
const connection = { id: randomUUID(), workspaceId: WS, provider: connector.manifest().id, channelId: CH, credentialId: { kind: "credential-ref" as const, id: "mock" }, fields: {}, createdAt: new Date() };
|
||||
const live = await connector.createLiveStream?.(connection, { channelId: CH, title: "test", permanent: false, saveReplay: false });
|
||||
if (!live) throw new Error("mock live missing");
|
||||
await connector.sendLiveChatMessage?.(connection, { liveRemoteId: live.remoteId, text: "hello through the wire" });
|
||||
const driver = new MockLivestreamDriver(connector, { workspaceId: WS, channelId: CH, connectionId: connection.id, liveRemoteId: live.remoteId });
|
||||
await driver.start((ws, events) => f.hub.ingest(ws, events));
|
||||
await driver.poll(); expect(f.db.messages).toHaveLength(1); expect(window).toHaveLength(1);
|
||||
const service = new RealtimeService(new MediaControlService({ signer: null, assignments: [] }), {
|
||||
subscriptions, authorizeChat: async () => true, channelExists: async () => true, chatWindow: async () => window.slice(), status: async () => ({ channels: [] }),
|
||||
});
|
||||
const wires: ClientWire[] = [];
|
||||
const open = (lastSeq: number) => {
|
||||
const wire = new ClientWire(), peer = new RealtimeSocket(wire), handlers = service.attach(peer);
|
||||
peer.onMessage = handlers.message; peer.onClose = handlers.close; wires.push(wire);
|
||||
wire.send({ op: "auth", token: issueSession({ userId: randomUUID(), workspaceId: WS, role: "Operator", authzEpoch: 1 }) });
|
||||
wire.send({ op: "subscribe", id: "chat", topic: "livechat", liveId: CH, lastSeq }); return wire;
|
||||
};
|
||||
try {
|
||||
const first = open(0); await settle();
|
||||
expect(first.frames).toContainEqual({ op: "ready" });
|
||||
expect(first.frames).toContainEqual({ op: "ack", id: "chat", fromSeq: 1 });
|
||||
expect(first.frames).toContainEqual(expect.objectContaining({ op: "msg", topic: "livechat", type: "chat.message", seq: 1, payload: expect.objectContaining({ seq: 1, message: expect.objectContaining({ seq: 1, text: "hello through the wire" }) }) }));
|
||||
await f.hub.ingest(WS, [event()]);
|
||||
expect(first.frames.filter(frame => frame.op === "msg").map(frame => frame.seq)).toEqual([1, 2]);
|
||||
const resume = open(1); await settle(); expect(resume.frames.filter(frame => frame.op === "msg").map(frame => frame.seq)).toEqual([2]);
|
||||
for (let i = 0; i < 500; i += 1) await f.hub.ingest(WS, [event()]);
|
||||
const stale = open(1); await settle(); expect(stale.frames).toContainEqual({ op: "resync", topic: "livechat" });
|
||||
expect(window).toHaveLength(500);
|
||||
const page = await f.hub.messages(WS, CH, { after: 1, limit: 100 }); expect(page.items[0]?.seq).toBe(2);
|
||||
} finally { await driver.stop(); service.onModuleDestroy(); bridge.onModuleDestroy(); for (const wire of wires) wire.destroy(); }
|
||||
});
|
||||
});
|
||||
34
apps/api/src/live-chat/idempotency.ts
Normal file
34
apps/api/src/live-chat/idempotency.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import type { LiveChatDependencies } from "./live-chat.service.js";
|
||||
|
||||
/** Reserve before contacting providers. A crash leaves a pending row, so an
|
||||
* ambiguous external send is never repeated automatically under the same key. */
|
||||
export async function idempotentChat<T>(transaction: LiveChatDependencies["transaction"], workspaceId: string, userId: string, route: string, key: string | undefined, body: unknown, action: () => Promise<T>): Promise<T> {
|
||||
if (!key || !/^[\x21-\x7e]{1,128}$/.test(key)) throw new BadRequestException("Idempotency-Key must contain 1..128 printable characters");
|
||||
const hash = createHash("sha256").update(JSON.stringify(body)).digest("hex");
|
||||
const existing = await transaction(workspaceId, async client => {
|
||||
const claim = await client.query(
|
||||
`INSERT INTO live_chat_requests (workspace_id,user_id,route,request_key,request_hash) VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT DO NOTHING RETURNING request_key`, [workspaceId, userId, route, key, hash]);
|
||||
if (claim.rowCount) return null;
|
||||
const row = await client.query<{ request_hash: string; status: string; response: T }>(
|
||||
"SELECT request_hash,status,response FROM live_chat_requests WHERE workspace_id=$1 AND user_id=$2 AND route=$3 AND request_key=$4", [workspaceId, userId, route, key]);
|
||||
return row.rows[0];
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.request_hash !== hash) throw new ConflictException("Idempotency-Key reused with different payload");
|
||||
if (existing.status !== "complete") throw new ConflictException("chat request pending or failed; reconcile before retrying");
|
||||
return existing.response;
|
||||
}
|
||||
try {
|
||||
const result = await action();
|
||||
await transaction(workspaceId, async client => { await client.query(
|
||||
"UPDATE live_chat_requests SET status='complete',response=$5 WHERE workspace_id=$1 AND user_id=$2 AND route=$3 AND request_key=$4", [workspaceId, userId, route, key, JSON.stringify(result)]); });
|
||||
return result;
|
||||
} catch (error) {
|
||||
await transaction(workspaceId, async client => { await client.query(
|
||||
"UPDATE live_chat_requests SET status='failed' WHERE workspace_id=$1 AND user_id=$2 AND route=$3 AND request_key=$4", [workspaceId, userId, route, key]); }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
28
apps/api/src/live-chat/live-chat.controller.test.ts
Normal file
28
apps/api/src/live-chat/live-chat.controller.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { PATH_METADATA } from "@nestjs/common/constants.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LiveChatModule } from "./live-chat.module.js";
|
||||
import { LiveChatController } from "./live-chat.controller.js";
|
||||
import { LiveChatHubService } from "./live-chat.service.js";
|
||||
import { DeviceService } from "./devices.js";
|
||||
|
||||
describe("standalone S5 module and REST routes", () => {
|
||||
it("boots with injected drivers and shuts them down without central AppModule wiring", async () => {
|
||||
const driver = { provider: "mock-livestream", start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) };
|
||||
const app = await NestFactory.createApplicationContext(LiveChatModule.register({ drivers: [driver] }), { logger: false, abortOnError: false });
|
||||
try { expect(app.get(LiveChatController)).toBeInstanceOf(LiveChatController); expect(driver.start).toHaveBeenCalledTimes(1); }
|
||||
finally { await app.close(); }
|
||||
expect(driver.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("matches §3.5 routes and rejects malformed redeem bodies before storage", async () => {
|
||||
const proto = LiveChatController.prototype;
|
||||
expect(Reflect.getMetadata(PATH_METADATA, proto.lives)).toBe("live-chat/lives");
|
||||
expect(Reflect.getMetadata(PATH_METADATA, proto.messages)).toBe("live-chat/lives/:channelId/messages");
|
||||
expect(Reflect.getMetadata(PATH_METADATA, proto.moderate)).toBe("live-chat/lives/:channelId/messages/:messageId/moderation");
|
||||
expect(Reflect.getMetadata(PATH_METADATA, proto.capabilities)).toBe("live-chat/lives/:channelId/capabilities");
|
||||
expect(Reflect.getMetadata(PATH_METADATA, proto.redeem)).toBe("devices/pairings/redeem");
|
||||
const controller = new LiveChatController(new LiveChatHubService(), new DeviceService());
|
||||
await expect(controller.redeem({ pairingCode: "ABCD-1234", deviceName: "" }, "key")).rejects.toThrow("deviceName");
|
||||
});
|
||||
});
|
||||
169
apps/api/src/live-chat/live-chat.controller.ts
Normal file
169
apps/api/src/live-chat/live-chat.controller.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { BadRequestException, Body, Controller, Delete, Get, Headers, HttpCode, Inject, Param, Post, Query, Req, UseGuards, NotFoundException, UnauthorizedException } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { idempotentChat } from "./idempotency.js";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { appendTenantAudit } from "../audit/audit.service.js";
|
||||
import { DEVICE_SCOPES } from "@tactical-media-broadcast-hub/core";
|
||||
import { parseId, parseLimit, requireObject, requireSession, requireString } from "../http/validation.js";
|
||||
import { RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { LiveChatGuard, SessionOnly } from "./live-chat.guard.js";
|
||||
import { tokenWorkspace } from "./auth.js";
|
||||
import { DeviceService } from "./devices.js";
|
||||
import { LiveChatHubService } from "./live-chat.service.js";
|
||||
|
||||
/**
|
||||
* §3.5 endpoints. No `/workspaces/{wid}` prefix — the workspace comes from the
|
||||
* session (or device token) per repo convention.
|
||||
*/
|
||||
@Controller()
|
||||
export class LiveChatController {
|
||||
constructor(@Inject(LiveChatHubService) private readonly hub: LiveChatHubService, @Inject(DeviceService) private readonly devices: DeviceService) {}
|
||||
|
||||
@Get("live-chat/lives")
|
||||
@UseGuards(LiveChatGuard)
|
||||
@RequirePermission("stream.view")
|
||||
async lives(@Req() request: Request & AuthenticatedRequest) {
|
||||
return this.hub.lives(requireSession(request).workspaceId);
|
||||
}
|
||||
|
||||
@Get("live-chat/lives/:channelId/messages")
|
||||
@UseGuards(LiveChatGuard)
|
||||
@RequirePermission("livechat.view")
|
||||
async messages(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Param("channelId") channelId: string,
|
||||
@Query("after") after: string | undefined,
|
||||
@Query("limit") limit: string | undefined,
|
||||
@Query("platform") platform: string | undefined,
|
||||
) {
|
||||
const session = requireSession(request);
|
||||
return this.hub.messages(session.workspaceId, parseId(channelId, "channelId"), {
|
||||
limit: parseLimit(limit, 100, 500),
|
||||
...(after === undefined || after === "" ? {} : { after: parseSeq(after) }),
|
||||
...(platform === undefined || platform === "" ? {} : { provider: platform }),
|
||||
});
|
||||
}
|
||||
|
||||
@Post("live-chat/lives/:channelId/messages")
|
||||
@UseGuards(LiveChatGuard)
|
||||
@RequirePermission("livechat.send")
|
||||
@HttpCode(202)
|
||||
async send(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Param("channelId") channelId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers("idempotency-key") key?: string,
|
||||
) {
|
||||
const session = requireSession(request);
|
||||
const parsed = requireObject(body, "body");
|
||||
const text = requireString(parsed.text, "text");
|
||||
if (text.length > 500) throw new BadRequestException("text must be 1..500 characters");
|
||||
const targets = parseTargets(parsed.targets);
|
||||
const liveId = parseId(channelId, "channelId");
|
||||
const replyTo = parsed.replyTo === undefined ? undefined : parseId(requireString(parsed.replyTo, "replyTo"), "replyTo");
|
||||
return idempotentChat(withTenant, session.workspaceId, session.userId, `send:${channelId}`, key, body, async () => {
|
||||
const outcome = await this.hub.send(session.workspaceId, liveId, {
|
||||
text, targets, ...(replyTo === undefined ? {} : { replyTo }),
|
||||
author: { handle: `${session.role.toLowerCase()}:${session.userId.slice(0, 8)}` },
|
||||
});
|
||||
return { message: outcome.message, delivery: outcome.delivery, ...(outcome.refused ? { refused: outcome.refused } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
@Post("live-chat/lives/:channelId/messages/:messageId/moderation")
|
||||
@UseGuards(LiveChatGuard)
|
||||
@RequirePermission("livechat.moderate")
|
||||
@HttpCode(202)
|
||||
async moderate(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Param("channelId") channelId: string,
|
||||
@Param("messageId") messageId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers("idempotency-key") key?: string,
|
||||
) {
|
||||
const session = requireSession(request);
|
||||
const parsed = requireObject(body, "body");
|
||||
const action = parsed.action;
|
||||
if (action !== "delete" && action !== "timeout" && action !== "ban") throw new BadRequestException("action must be delete, timeout or ban");
|
||||
let durationSeconds: number | undefined;
|
||||
if (parsed.durationSeconds !== undefined) {
|
||||
if (typeof parsed.durationSeconds !== "number") throw new BadRequestException("durationSeconds must be a number");
|
||||
durationSeconds = parsed.durationSeconds;
|
||||
if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 1209600) throw new BadRequestException("durationSeconds must be 1..1209600");
|
||||
}
|
||||
if (action === "timeout" && durationSeconds === undefined) throw new BadRequestException("timeout requires durationSeconds");
|
||||
return idempotentChat(withTenant, session.workspaceId, session.userId, `moderate:${channelId}:${messageId}`, key, body, async () => {
|
||||
const outcome = await this.hub.moderate(session.workspaceId, parseId(channelId, "channelId"), {
|
||||
action,
|
||||
...(durationSeconds === undefined ? {} : { durationSeconds }),
|
||||
message: await this.hubTarget(session.workspaceId, parseId(channelId, "channelId"), parseId(messageId, "messageId")),
|
||||
});
|
||||
await appendTenantAudit(session.workspaceId, { actorUserId: session.userId, actorRole: session.role, action: `livechat.${action}`,
|
||||
targetType: "live_chat_message", targetId: messageId, diff: { delivery: outcome.delivery, durationSeconds } });
|
||||
return { delivery: outcome.delivery };
|
||||
});
|
||||
}
|
||||
|
||||
@Get("live-chat/lives/:channelId/capabilities")
|
||||
@UseGuards(LiveChatGuard)
|
||||
@RequirePermission("livechat.view")
|
||||
async capabilities(@Req() request: Request & AuthenticatedRequest, @Param("channelId") channelId: string) {
|
||||
const session = requireSession(request);
|
||||
return this.hub.capabilities(session.workspaceId, parseId(channelId, "channelId"));
|
||||
}
|
||||
|
||||
// -- Device pairing (§3.4) ------------------------------------------------
|
||||
|
||||
@Post("devices/pairings")
|
||||
@SessionOnly()
|
||||
@UseGuards(LiveChatGuard)
|
||||
@RequirePermission("livechat.send")
|
||||
async issuePairing(@Req() request: Request & AuthenticatedRequest, @Headers("idempotency-key") key?: string): Promise<{ pairingCode: string; qrPayload: string; expiresAt: string }> {
|
||||
const session = requireSession(request);
|
||||
return idempotentChat(withTenant, session.workspaceId, session.userId, "pairing", key, {}, () => this.devices.issuePairing(session.workspaceId, session.userId, DEVICE_SCOPES));
|
||||
}
|
||||
|
||||
@Post("devices/pairings/redeem")
|
||||
@HttpCode(200)
|
||||
async redeem(@Body() body: unknown, @Headers("idempotency-key") key?: string, @Headers("authorization") auth?: string): Promise<{ deviceToken: string; workspaceId: string }> {
|
||||
const parsed = requireObject(body, "body");
|
||||
const pairingCode = requireString(parsed.pairingCode, "pairingCode");
|
||||
const deviceName = requireString(parsed.deviceName, "deviceName");
|
||||
if (deviceName.length > 120) throw new BadRequestException("deviceName too long");
|
||||
// The bearer pairing code resolves its issuing workspace; no session is needed.
|
||||
const workspace = auth?.startsWith("Bearer ") ? tokenWorkspace(auth.slice(7)) : undefined;
|
||||
if (auth && !workspace) throw new UnauthorizedException();
|
||||
return this.devices.redeemCode(pairingCode, deviceName, key, workspace ?? undefined);
|
||||
}
|
||||
|
||||
@Delete("devices/:deviceId")
|
||||
@UseGuards(LiveChatGuard)
|
||||
@RequirePermission("livechat.send")
|
||||
async revoke(@Req() request: Request & AuthenticatedRequest, @Param("deviceId") deviceId: string) {
|
||||
const session = requireSession(request);
|
||||
await this.devices.revoke(session.workspaceId, parseId(deviceId, "deviceId"));
|
||||
return { revoked: true };
|
||||
}
|
||||
|
||||
private async hubTarget(workspaceId: string, channelId: string, messageId: string) {
|
||||
const found = await this.hub.findMessage(workspaceId, channelId, messageId);
|
||||
if (!found) throw new NotFoundException("message not found");
|
||||
return { id: found.id, connectionId: found.connectionId, provider: found.provider, providerMessageId: found.providerMessageId, authorHandle: found.author.handle };
|
||||
}
|
||||
}
|
||||
|
||||
function parseSeq(value: string): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isSafeInteger(n) || n < 0) throw new BadRequestException("after must be a non-negative integer seq");
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseTargets(value: unknown): "all" | string[] {
|
||||
if (value === "all") return "all";
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > 32) throw new BadRequestException('targets must be "all" or 1..32 connection ids');
|
||||
return value.map((entry): string => {
|
||||
if (typeof entry !== "string") throw new BadRequestException("targets must contain connection ids");
|
||||
return parseId(entry, "connectionId");
|
||||
});
|
||||
}
|
||||
|
||||
26
apps/api/src/live-chat/live-chat.guard.ts
Normal file
26
apps/api/src/live-chat/live-chat.guard.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { CanActivate, ExecutionContext, Inject, Injectable, SetMetadata, UnauthorizedException, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { PERMISSION_KEY, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { pool } from "../db/client.js";
|
||||
import { authorizeChat, chatSession, tokenWorkspace } from "./auth.js";
|
||||
|
||||
export const SessionOnly = (): MethodDecorator => SetMetadata("livechat.session_only", true) as MethodDecorator;
|
||||
|
||||
@Injectable()
|
||||
export class LiveChatGuard implements CanActivate {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<{ headers: Record<string, string | undefined> } & AuthenticatedRequest>();
|
||||
const token = req.headers.authorization?.replace(/^Bearer /, "");
|
||||
const workspaceId = token ? tokenWorkspace(token) : null;
|
||||
if (!token || !workspaceId || !req.headers.authorization?.startsWith("Bearer ")) throw new UnauthorizedException();
|
||||
if (this.reflector.get<boolean>("livechat.session_only", context.getHandler()) && !chatSession(token)) throw new UnauthorizedException("session user required");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const session = await authorizeChat(client, token, workspaceId, this.reflector.get<string>(PERMISSION_KEY, context.getHandler()) ?? "livechat.view");
|
||||
if (!session) throw new ForbiddenException("chat permission or authorization epoch invalid");
|
||||
req.session = session;
|
||||
return true;
|
||||
} finally { client.release(); }
|
||||
}
|
||||
}
|
||||
97
apps/api/src/live-chat/live-chat.integration.test.ts
Normal file
97
apps/api/src/live-chat/live-chat.integration.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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";
|
||||
import { LiveChatHubService } from "./live-chat.service.js";
|
||||
import { DeviceService, verifyDeviceToken } from "./devices.js";
|
||||
import { idempotentChat } from "./idempotency.js";
|
||||
import type { NormalizedChatEvent } from "./types.js";
|
||||
|
||||
// Deliberately isolated from the user's configured runtime database.
|
||||
const url = process.env.S5_TEST_DATABASE_URL;
|
||||
const db = new Pool({ connectionString: url });
|
||||
const WS = randomUUID(), OTHER = randomUUID(), USER = randomUUID(), CH = randomUUID(), CONN = randomUUID();
|
||||
async function tenant<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await db.connect();
|
||||
try {
|
||||
await client.query("BEGIN"); await client.query("SET LOCAL ROLE sp_app");
|
||||
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"); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
const hub = new LiveChatHubService({ transaction: tenant, publish: () => undefined });
|
||||
function event(id: string, receivedAt = new Date()): NormalizedChatEvent {
|
||||
return { provider: "mock-livestream", connectionId: CONN, channelId: CH, providerMessageId: id, author: { handle: "viewer" }, text: id, sentAt: receivedAt, receivedAt };
|
||||
}
|
||||
describe.skipIf(!url)("S5 PostgreSQL partitioning, row locks, RLS, pairing, and retention", () => {
|
||||
beforeAll(async () => {
|
||||
process.env.SUPER_DATABASE_URL = url;
|
||||
await runMigrations(join(__dirname, "../../migrations"));
|
||||
await db.query("INSERT INTO workspaces(id,name,slug) VALUES ($1::uuid,'S5',$1::text),($2::uuid,'S5 other',$2::text)", [WS, OTHER]);
|
||||
await db.query("INSERT INTO users(id,external_id,email,display_name) VALUES ($1::uuid,$1::text,'s5@example.test','S5')", [USER]);
|
||||
await db.query("INSERT INTO memberships(workspace_id,user_id,role) VALUES ($1,$2,'Owner')", [WS, USER]);
|
||||
await db.query("INSERT INTO connections(id,workspace_id,provider,channel_id) VALUES ($1,$2,'mock-livestream','remote')", [CONN, WS]);
|
||||
await db.query("INSERT INTO virtual_channels(id,workspace_id,slug,name) VALUES ($1,$2,'s5','S5')", [CH, WS]);
|
||||
await db.query("INSERT INTO virtual_channel_sources(workspace_id,virtual_channel_id,kind,label,connection_id,remote_ref,hls_url) VALUES ($1,$2,'hls','S5',$3,'remote','https://example.test/live.m3u8')", [WS, CH, CONN]);
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (url) { await db.query("DELETE FROM workspaces WHERE id IN ($1,$2)", [WS, OTHER]); await db.query("DELETE FROM users WHERE id=$1", [USER]); }
|
||||
await db.end();
|
||||
});
|
||||
it("concurrent ingress assigns unique seqs; duplicates claim exactly one durable row", async () => {
|
||||
const results = await Promise.all([hub.ingest(WS, [event("same")]), hub.ingest(WS, [event("same")])]);
|
||||
expect(results.flatMap(result => result.inserted)).toHaveLength(1);
|
||||
expect(results.flatMap(result => result.duplicates)).toEqual(["same"]);
|
||||
const more = await Promise.all(Array.from({ length: 8 }, (_, i) => hub.ingest(WS, [event(`unique-${i}`)])));
|
||||
expect(more.flatMap(result => result.inserted.map(message => message.seq)).sort((a,b) => a-b)).toEqual([2,3,4,5,6,7,8,9]);
|
||||
expect((await hub.messages(OTHER, CH, { limit: 100 })).items).toEqual([]);
|
||||
expect((await hub.lives(WS)).items).toContainEqual(expect.objectContaining({ liveStreamId: CH, connectionId: CONN }));
|
||||
});
|
||||
it("RLS applies to direct partition access as well as parent access", async () => {
|
||||
const partitions = await db.query<{ relname: string }>("SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid=i.inhrelid WHERE i.inhparent='live_chat_messages'::regclass");
|
||||
for (const row of partitions.rows) {
|
||||
if (!/^live_chat_messages_(default|[0-9]{6})$/.test(row.relname)) throw new Error("unexpected partition name");
|
||||
const found = await tenant(OTHER, client => client.query(`SELECT * FROM ${row.relname}`)); expect(found.rowCount).toBe(0);
|
||||
}
|
||||
const client = await db.connect();
|
||||
try { await client.query("BEGIN"); await client.query("SET LOCAL ROLE sp_app"); expect((await client.query("SELECT * FROM live_chat_messages")).rowCount).toBe(0); }
|
||||
finally { await client.query("ROLLBACK"); client.release(); }
|
||||
});
|
||||
it("pairing lookup permits only a valid code; redemption is single use and workspace fenced", async () => {
|
||||
const service = new DeviceService({ transaction: tenant, serverUrl: "https://studio.example", lookupWorkspace: async code => {
|
||||
const row = await db.query<{ workspace_id: string }>("SELECT device_pairing_workspace($1) AS workspace_id", [code]); return row.rows[0]?.workspace_id ?? null;
|
||||
} });
|
||||
const pairing = await service.issuePairing(WS, USER);
|
||||
const lookup = await tenant(OTHER, client => client.query<{ workspace_id: string }>("SELECT device_pairing_workspace($1) AS workspace_id", [pairing.pairingCode]));
|
||||
expect(lookup.rows[0]?.workspace_id).toBe(WS);
|
||||
await expect(service.redeem(OTHER, pairing.pairingCode, "phone")).rejects.toThrow("invalid pairing code");
|
||||
await expect(service.redeemCode(pairing.pairingCode, "phone", "redeem-key", OTHER)).rejects.toThrow("invalid pairing code");
|
||||
const result = await service.redeemCode(pairing.pairingCode, "phone", "redeem-key"), payload = verifyDeviceToken(result.deviceToken);
|
||||
expect(payload).toMatchObject({ userId: USER, workspaceId: WS, epoch: 1 });
|
||||
expect(await service.redeemCode(pairing.pairingCode, "phone", "redeem-key")).toEqual(result);
|
||||
if (!payload) throw new Error("no token");
|
||||
await expect(service.redeem(WS, pairing.pairingCode, "phone")).rejects.toThrow("already redeemed");
|
||||
await service.revoke(WS, payload.deviceId); expect(await service.epochOf(WS, payload.deviceId)).toBeNull();
|
||||
});
|
||||
it("idempotency returns the recorded reply and refuses concurrent or changed requests", async () => {
|
||||
let calls = 0;
|
||||
const run = (body: unknown) => idempotentChat(tenant, WS, USER, "send:test", "retry-key", body, async () => ({ count: ++calls }));
|
||||
expect(await run({ text: "hi" })).toEqual({ count: 1 });
|
||||
expect(await run({ text: "hi" })).toEqual({ count: 1 }); expect(calls).toBe(1);
|
||||
await expect(run({ text: "changed" })).rejects.toThrow("different payload");
|
||||
const result = await Promise.allSettled([idempotentChat(tenant, WS, USER, "send:test", "concurrent", {}, async () => ({ count: ++calls })), idempotentChat(tenant, WS, USER, "send:test", "concurrent", {}, async () => ({ count: ++calls }))]);
|
||||
expect(result.filter(item => item.status === "fulfilled").length).toBeGreaterThanOrEqual(1); expect(calls).toBe(2);
|
||||
});
|
||||
it("retention prunes old messages without pruning another tenant", async () => {
|
||||
const old = new Date(Date.now() - 100 * 86400000);
|
||||
await hub.ingest(WS, [event("old-retention", old)]);
|
||||
const before = (await hub.messages(WS, CH, { limit: 100 })).items.length;
|
||||
await tenant(OTHER, client => client.query("SELECT live_chat_prune($1,$2)", [OTHER, new Date()]));
|
||||
expect((await hub.messages(WS, CH, { limit: 100 })).items).toHaveLength(before);
|
||||
const removed = await tenant(WS, client => client.query<{ count: number }>("SELECT live_chat_prune($1,$2) AS count", [WS, new Date(Date.now() - 30 * 86400000)]));
|
||||
expect(removed.rows[0]?.count).toBe(1);
|
||||
expect((await hub.messages(WS, CH, { limit: 100 })).items.some(message => message.providerMessageId === "old-retention")).toBe(false);
|
||||
});
|
||||
});
|
||||
53
apps/api/src/live-chat/live-chat.module.ts
Normal file
53
apps/api/src/live-chat/live-chat.module.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Module, type DynamicModule } from "@nestjs/common";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { LIVE_CHAT_RUNTIME, LiveChatRuntime, type LiveChatRuntimeOptions } from "./runtime.js";
|
||||
import { ChatRealtimeBridge } from "./bridge.js";
|
||||
import { LiveChatGuard } from "./live-chat.guard.js";
|
||||
import { DeviceService } from "./devices.js";
|
||||
import { LiveChatController } from "./live-chat.controller.js";
|
||||
import { LiveChatHubService } from "./live-chat.service.js";
|
||||
import { ChatRingBuffer } from "./ring-buffer.js";
|
||||
import { RedisChatGovernors, type ChatGovernors } from "./redis-governors.js";
|
||||
import { SendGovernors } from "./governors.js";
|
||||
import type { ChatTarget } from "./types.js";
|
||||
|
||||
/** Central review imports this module with credential-bound transports,
|
||||
* capability targets and ingestion drivers via LiveChatModule.register(). */
|
||||
function defaultTargets(): (workspaceId: string, channelId: string) => Promise<ChatTarget[]> {
|
||||
return async (workspaceId, channelId) => {
|
||||
const result = await withTenant(workspaceId, client => client.query<{ connection_id: string; provider: string; live_remote_id: string | null }>(
|
||||
`SELECT vc.id AS connection_id, vc.provider, vcs.remote_ref AS live_remote_id
|
||||
FROM virtual_channel_sources vcs
|
||||
JOIN connections vc ON vc.id = vcs.connection_id AND vc.workspace_id = vcs.workspace_id
|
||||
WHERE vcs.workspace_id = $1 AND vcs.virtual_channel_id = $2`, [workspaceId, channelId]));
|
||||
return result.rows.map(row => ({
|
||||
connectionId: row.connection_id,
|
||||
provider: row.provider,
|
||||
liveRemoteId: row.live_remote_id ?? row.connection_id,
|
||||
read: false, reply: false, moderate: false, state: "degraded" as const,
|
||||
reason: "chat adapter not configured",
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
@Module({
|
||||
controllers: [LiveChatController],
|
||||
providers: [
|
||||
{ provide: ChatRingBuffer, useValue: new ChatRingBuffer(500) },
|
||||
{ provide: SendGovernors, useFactory: () => new RedisChatGovernors({ buckets: { twitch: { capacity: 100, refillPerSecond: 100 / 30 } }, quotas: { youtube: { budget: 10000, costPerCall: 50 } } }) },
|
||||
{ provide: ChatRealtimeBridge, useFactory: () => new ChatRealtimeBridge() },
|
||||
{
|
||||
provide: LiveChatHubService,
|
||||
useFactory: (buffer: ChatRingBuffer, governors: ChatGovernors, bridge: ChatRealtimeBridge, runtime?: LiveChatRuntimeOptions) =>
|
||||
new LiveChatHubService({ buffer, governors, publish: bridge.publish, targets: defaultTargets(), ...runtime }),
|
||||
inject: [ChatRingBuffer, SendGovernors, ChatRealtimeBridge, { token: LIVE_CHAT_RUNTIME, optional: true }],
|
||||
},
|
||||
DeviceService, LiveChatGuard, LiveChatRuntime,
|
||||
],
|
||||
exports: [LiveChatHubService, DeviceService, ChatRealtimeBridge],
|
||||
})
|
||||
export class LiveChatModule {
|
||||
static register(options: LiveChatRuntimeOptions): DynamicModule {
|
||||
return { module: LiveChatModule, providers: [{ provide: LIVE_CHAT_RUNTIME, useValue: options }] };
|
||||
}
|
||||
}
|
||||
353
apps/api/src/live-chat/live-chat.service.ts
Normal file
353
apps/api/src/live-chat/live-chat.service.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* Chat hub service: ingestion → seq assignment → persistence (dedupe) →
|
||||
* fan-out on the realtime gateway, plus reply/moderation fan-out with
|
||||
* per-target Delivery rows and send governors in front.
|
||||
*
|
||||
* Sequencing is per (workspace, channel) under a row lock on `live_chat_seq`
|
||||
* (§3.2): the ingest transaction takes FOR UPDATE on that row, so concurrent
|
||||
* ingests cannot interleave seqs for one channel. Provider message ids dedupe
|
||||
* through UNIQUE(workspace_id, provider, provider_msg_id) — a replay is a
|
||||
* no-op that publishes nothing.
|
||||
*
|
||||
* Every port has a working default (see the module), and tests inject
|
||||
* overrides through LIVE_CHAT_DEPENDENCIES — same pattern as RealtimeService.
|
||||
*/
|
||||
import { Inject, Injectable, Optional, NotFoundException } from "@nestjs/common";
|
||||
import type { PoolClient } from "pg";
|
||||
import { authorColor, type Delivery, type LiveChatCapability, type LiveSummary } from "@tactical-media-broadcast-hub/core";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { ChatRingBuffer } from "./ring-buffer.js";
|
||||
import type { ChatGovernors } from "./redis-governors.js";
|
||||
import { SendGovernors } from "./governors.js";
|
||||
import type {
|
||||
ChatTarget, ChatTransport, IngestResult, MessagePage, MessageQuery, ModerateOutcome, NormalizedChatEvent, SendOutcome, StoredChatMessage,
|
||||
} from "./types.js";
|
||||
|
||||
export const LIVE_CHAT_DEPENDENCIES = Symbol("LIVE_CHAT_DEPENDENCIES");
|
||||
|
||||
export type LiveChatPublish = (workspaceId: string, channelId: string, frame: Record<string, unknown>) => void | Promise<void>;
|
||||
|
||||
export interface LiveChatDependencies {
|
||||
transaction: <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
|
||||
publish: LiveChatPublish;
|
||||
buffer: ChatRingBuffer;
|
||||
governors: ChatGovernors;
|
||||
/** Resolve the send/moderate targets for a channel (capability rows). */
|
||||
targets: (workspaceId: string, channelId: string) => Promise<ChatTarget[]>;
|
||||
/** Transport per provider id. */
|
||||
transports: Record<string, ChatTransport>;
|
||||
/** Virtual clock for deterministic tests. */
|
||||
now?: () => Date;
|
||||
filters?: { hideCommands?: boolean; hideBots?: boolean; mutedHandles?: string[]; words?: string[] };
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string; workspace_id: string; channel_id: string; connection_id: string; provider: string; provider_msg_id: string;
|
||||
author_handle: string; author_display_name: string | null; author_avatar_url: string | null; author_badges: unknown;
|
||||
author_color: string; body: string; reply_to: string | null; seq: string | number; sent_at: Date; received_at: Date;
|
||||
is_moderated: boolean; source: string;
|
||||
}
|
||||
|
||||
function toStored(row: MessageRow): StoredChatMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
seq: Number(row.seq),
|
||||
liveStreamId: row.channel_id,
|
||||
connectionId: row.connection_id,
|
||||
provider: row.provider,
|
||||
providerMessageId: row.provider_msg_id,
|
||||
author: {
|
||||
handle: row.author_handle,
|
||||
...(row.author_display_name === null ? {} : { displayName: row.author_display_name }),
|
||||
...(row.author_avatar_url === null ? {} : { avatarUrl: row.author_avatar_url }),
|
||||
badges: Array.isArray(row.author_badges) ? (row.author_badges as string[]) : [],
|
||||
color: row.author_color,
|
||||
},
|
||||
text: row.body,
|
||||
sentAt: new Date(row.sent_at).toISOString(),
|
||||
receivedAt: new Date(row.received_at).toISOString(),
|
||||
isModerated: row.is_moderated,
|
||||
...(row.reply_to === null ? {} : { replyTo: row.reply_to }),
|
||||
source: row.source as StoredChatMessage["source"],
|
||||
};
|
||||
}
|
||||
|
||||
const SELECT_COLUMNS = `id, workspace_id, channel_id, connection_id, provider, provider_msg_id, author_handle,
|
||||
author_display_name, author_avatar_url, author_badges, author_color, body, reply_to, seq, sent_at,
|
||||
received_at, is_moderated, source`;
|
||||
|
||||
const INSERT_SQL = `INSERT INTO live_chat_messages (workspace_id, channel_id, connection_id, provider, provider_msg_id,
|
||||
author_handle, author_display_name, author_avatar_url, author_badges, author_color, body, reply_to,
|
||||
seq, sent_at, received_at, source)
|
||||
SELECT * FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[], $4::text[], $5::text[], $6::text[], $7::text[],
|
||||
$8::text[], $9::jsonb[], $10::text[], $11::text[], $12::text[], $13::bigint[], $14::timestamptz[],
|
||||
$15::timestamptz[], $16::text[])
|
||||
RETURNING ${SELECT_COLUMNS}`;
|
||||
|
||||
@Injectable()
|
||||
export class LiveChatHubService {
|
||||
private readonly deps: LiveChatDependencies;
|
||||
private readonly now: () => Date;
|
||||
constructor(@Optional() @Inject(LIVE_CHAT_DEPENDENCIES) dependencies?: Partial<LiveChatDependencies>) {
|
||||
this.deps = {
|
||||
transaction: dependencies?.transaction ?? withTenant,
|
||||
publish: dependencies?.publish ?? (() => undefined),
|
||||
buffer: dependencies?.buffer ?? new ChatRingBuffer(500),
|
||||
governors: dependencies?.governors ?? new SendGovernors(),
|
||||
targets: dependencies?.targets ?? (async () => []),
|
||||
transports: dependencies?.transports ?? {},
|
||||
...(dependencies?.now ? { now: dependencies.now } : {}),
|
||||
...(dependencies?.filters ? { filters: dependencies.filters } : {}),
|
||||
};
|
||||
this.now = this.deps.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async lives(workspaceId: string): Promise<{ items: LiveSummary[] }> {
|
||||
return this.deps.transaction(workspaceId, async client => {
|
||||
const rows = await client.query<{ channel_id: string; connection_id: string; provider: string; title: string; state: LiveSummary["state"] }>(
|
||||
`SELECT DISTINCT vc.id AS channel_id, c.id AS connection_id, c.provider, vc.name AS title,
|
||||
CASE WHEN vc.observed_status='RUNNING' THEN 'live' WHEN vc.desired_status='RUNNING' THEN 'waiting' ELSE 'ended' END AS state
|
||||
FROM virtual_channels vc JOIN virtual_channel_sources s ON s.virtual_channel_id=vc.id AND s.workspace_id=vc.workspace_id
|
||||
JOIN connections c ON c.id=s.connection_id AND c.workspace_id=s.workspace_id
|
||||
WHERE vc.workspace_id=$1 ORDER BY vc.id, c.id`, [workspaceId]);
|
||||
return { items: rows.rows.map(row => ({ liveStreamId: row.channel_id, connectionId: row.connection_id, provider: row.provider, title: row.title, state: row.state })) };
|
||||
});
|
||||
}
|
||||
|
||||
private async nextSeq(workspaceId: string, channelId: string): Promise<number> {
|
||||
return this.deps.transaction(workspaceId, async client => {
|
||||
await client.query("INSERT INTO live_chat_seq (workspace_id, channel_id, seq) VALUES ($1,$2,0) ON CONFLICT DO NOTHING", [workspaceId, channelId]);
|
||||
const locked = await client.query<{ seq: string | number }>("SELECT seq FROM live_chat_seq WHERE workspace_id=$1 AND channel_id=$2 FOR UPDATE", [workspaceId, channelId]);
|
||||
const seq = Number(locked.rows[0]?.seq ?? 0) + 1;
|
||||
await client.query("UPDATE live_chat_seq SET seq = GREATEST(seq, $3), updated_at = now() WHERE workspace_id=$1 AND channel_id=$2", [workspaceId, channelId, seq]);
|
||||
return seq;
|
||||
});
|
||||
}
|
||||
|
||||
/** Ingest a batch: assign seqs under the channel row lock, insert, publish. */
|
||||
async ingest(workspaceId: string, events: NormalizedChatEvent[]): Promise<IngestResult> {
|
||||
if (events.length === 0) return { inserted: [], duplicates: [] };
|
||||
const byChannel = new Map<string, NormalizedChatEvent[]>();
|
||||
for (const event of events) {
|
||||
const filters = this.deps.filters;
|
||||
if ((filters?.hideCommands && event.text.startsWith("!")) || (filters?.hideBots && event.author.isBot) ||
|
||||
filters?.mutedHandles?.includes(`${event.provider}:${event.author.handle}`) ||
|
||||
filters?.words?.some(word => word.length > 0 && event.text.toLowerCase().includes(word.toLowerCase()))) continue;
|
||||
const list = byChannel.get(event.channelId) ?? [];
|
||||
list.push(event);
|
||||
byChannel.set(event.channelId, list);
|
||||
}
|
||||
const inserted: StoredChatMessage[] = [];
|
||||
const duplicates: string[] = [];
|
||||
for (const [channelId, batch] of byChannel) {
|
||||
const rows = await this.deps.transaction(workspaceId, async client => {
|
||||
// Create-or-take the channel's seq row. INSERT ... ON CONFLICT first so
|
||||
// two first-ever ingests cannot both see "no row" and deadlock-fail.
|
||||
await client.query(
|
||||
`INSERT INTO live_chat_seq (workspace_id, channel_id, seq) VALUES ($1,$2,0) ON CONFLICT DO NOTHING`,
|
||||
[workspaceId, channelId]);
|
||||
const locked = await client.query<{ seq: string | number }>(
|
||||
"SELECT seq FROM live_chat_seq WHERE workspace_id=$1 AND channel_id=$2 FOR UPDATE", [workspaceId, channelId]);
|
||||
const next = Number(locked.rows[0]?.seq ?? 0);
|
||||
const claimed: NormalizedChatEvent[] = [];
|
||||
for (const event of batch) {
|
||||
const claim = await client.query(
|
||||
"INSERT INTO live_chat_seen_events (workspace_id, provider, provider_event_id) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING provider_event_id",
|
||||
[workspaceId, event.provider, `message:${event.providerMessageId}`]);
|
||||
if (claim.rowCount) claimed.push(event);
|
||||
}
|
||||
if (claimed.length === 0) return [];
|
||||
const values = claimed.map((event, index) => [
|
||||
workspaceId, channelId, event.connectionId, event.provider, event.providerMessageId,
|
||||
event.author.handle, event.author.displayName ?? null, event.author.avatarUrl ?? null,
|
||||
JSON.stringify(event.author.badges ?? []), authorColor(event.provider, event.author.handle),
|
||||
event.text, event.replyTo ?? null, next + index + 1, event.sentAt, event.receivedAt ?? this.now(),
|
||||
event.source ?? "live-chat",
|
||||
]);
|
||||
const columns = Array.from({ length: 16 }, (_, i) => values.map(row => row[i]));
|
||||
const result = await client.query<MessageRow>(INSERT_SQL, columns);
|
||||
await client.query(
|
||||
"UPDATE live_chat_seq SET seq = GREATEST(seq, $3), updated_at = now() WHERE workspace_id=$1 AND channel_id=$2",
|
||||
[workspaceId, channelId, next + claimed.length]);
|
||||
return result.rows.map(toStored);
|
||||
});
|
||||
const insertedIds = new Map<string, number>();
|
||||
for (const row of rows) { const key = `${row.provider}:${row.providerMessageId}`; insertedIds.set(key, (insertedIds.get(key) ?? 0) + 1); }
|
||||
for (const message of rows) { this.deps.buffer.push(message); inserted.push(message); }
|
||||
for (const event of batch) {
|
||||
const key = `${event.provider}:${event.providerMessageId}`, count = insertedIds.get(key) ?? 0;
|
||||
if (count === 0) duplicates.push(event.providerMessageId); else insertedIds.set(key, count - 1);
|
||||
}
|
||||
for (const message of rows) {
|
||||
await this.deps.publish(workspaceId, channelId, {
|
||||
kind: "chat", liveStreamId: channelId, channelId, seq: message.seq, message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { inserted, duplicates };
|
||||
}
|
||||
|
||||
/** A provider disconnect is accepted explicitly, with a shared system notice. */
|
||||
async gap(workspaceId: string, event: NormalizedChatEvent, reason: string): Promise<void> {
|
||||
const result = await this.ingest(workspaceId, [{ ...event, text: `Chat gap: ${reason}`, source: "system" }]);
|
||||
if (result.inserted.length > 0) await this.deps.publish(workspaceId, event.channelId, {
|
||||
kind: "chat", liveStreamId: event.channelId, channelId: event.channelId,
|
||||
seq: await this.nextSeq(workspaceId, event.channelId), provider: event.provider, reason,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cursor page from the durable store (REST fallback path). */
|
||||
async messages(workspaceId: string, channelId: string, query: MessageQuery): Promise<MessagePage> {
|
||||
return this.deps.transaction(workspaceId, async client => {
|
||||
const params: unknown[] = [workspaceId, channelId, query.after ?? 0, query.limit];
|
||||
let filter = "";
|
||||
if (query.provider !== undefined) { params.push(query.provider); filter = " AND provider=$5"; }
|
||||
const result = await client.query<MessageRow>(
|
||||
`SELECT ${SELECT_COLUMNS} FROM live_chat_messages WHERE workspace_id=$1 AND channel_id=$2 AND seq > $3${filter}
|
||||
ORDER BY seq ASC LIMIT $4`, params);
|
||||
const items = result.rows.map(toStored);
|
||||
const nextCursor = items.length === query.limit ? items.at(-1)?.seq : undefined;
|
||||
return { items, ...(nextCursor === undefined ? {} : { nextCursor }) };
|
||||
});
|
||||
}
|
||||
|
||||
/** One message by id (moderation target lookup). */
|
||||
async findMessage(workspaceId: string, channelId: string, messageId: string): Promise<StoredChatMessage | null> {
|
||||
return this.deps.transaction(workspaceId, async client => {
|
||||
const result = await client.query<MessageRow>(
|
||||
`SELECT ${SELECT_COLUMNS} FROM live_chat_messages WHERE workspace_id=$1 AND channel_id=$2 AND id=$3`,
|
||||
[workspaceId, channelId, messageId]);
|
||||
return result.rows[0] ? toStored(result.rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Latest window straight from the ring buffer (resume path). */
|
||||
resume(liveStreamId: string, lastSeq: number, limit = 500): { messages: StoredChatMessage[]; resync: boolean } {
|
||||
if (!this.deps.buffer.canResume(liveStreamId, lastSeq)) return { messages: [], resync: true };
|
||||
return { messages: this.deps.buffer.after(liveStreamId, lastSeq, limit), resync: false };
|
||||
}
|
||||
|
||||
/** Per-connection capabilities for a channel (§3.5 capabilities endpoint). */
|
||||
async capabilities(workspaceId: string, channelId: string): Promise<LiveChatCapability[]> {
|
||||
return Promise.all((await this.deps.targets(workspaceId, channelId)).map(async target => {
|
||||
const governor = await Promise.resolve().then(() => this.deps.governors.probe(target.provider)).catch(() => ({ state: "degraded" as const, reason: "governor unavailable" }));
|
||||
return {
|
||||
connectionId: target.connectionId, provider: target.provider, read: target.read, reply: target.reply,
|
||||
moderate: target.moderate, state: governor.state === "ok" ? target.state : governor.state ?? target.state,
|
||||
...(target.reason === undefined && governor.reason === undefined ? {} : { reason: governor.reason ?? target.reason }),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/** Reply fan-out: governor per target, one Delivery row per target, and one
|
||||
* stored hub message when at least one target accepted. An unsupported or
|
||||
* failed target NEVER fails the batch (§3.5). */
|
||||
async send(workspaceId: string, channelId: string, request: {
|
||||
text: string; targets: "all" | string[]; replyTo?: string; author: { handle: string };
|
||||
}): Promise<SendOutcome> {
|
||||
const targets = await this.deps.targets(workspaceId, channelId);
|
||||
const parent = request.replyTo ? await this.findMessage(workspaceId, channelId, request.replyTo) : null;
|
||||
if (request.replyTo && !parent) throw new NotFoundException("reply target not found");
|
||||
const selected = request.targets === "all" ? targets : targets.filter(t => request.targets.includes(t.connectionId));
|
||||
if (selected.length === 0) return { message: null, delivery: [], refused: { reason: "no capable target", state: "degraded" } };
|
||||
const delivery = new Map<string, Delivery>();
|
||||
const accepted: ChatTarget[] = [];
|
||||
let refused: SendOutcome["refused"];
|
||||
for (const target of selected) {
|
||||
if (!target.reply || !this.deps.transports[target.provider]) {
|
||||
delivery.set(target.connectionId, { connectionId: target.connectionId, provider: target.provider, status: "unsupported", error: target.reason ?? (!target.reply ? "reply not supported" : "no transport configured") });
|
||||
continue;
|
||||
}
|
||||
const verdict = await Promise.resolve().then(() => this.deps.governors.take(target.provider)).catch(() => ({ allowed: false, state: "degraded" as const, reason: "governor unavailable" }));
|
||||
if (!verdict.allowed) {
|
||||
refused ??= { state: verdict.state ?? "degraded", reason: verdict.reason ?? "refused" };
|
||||
delivery.set(target.connectionId, { connectionId: target.connectionId, provider: target.provider, status: "unsupported", error: verdict.reason ?? "refused" });
|
||||
continue;
|
||||
}
|
||||
delivery.set(target.connectionId, { connectionId: target.connectionId, provider: target.provider, status: "queued" });
|
||||
accepted.push(target);
|
||||
}
|
||||
if (accepted.length === 0) {
|
||||
const first = [...delivery.values()].find(d => d.error);
|
||||
return { message: null, delivery: [...delivery.values()], refused: { reason: refused?.reason ?? first?.error ?? "no target accepted", state: refused?.state ?? "degraded" } };
|
||||
}
|
||||
const outcomes = new Map<string, { providerMessageId: string; sentAt: Date }>();
|
||||
for (const target of accepted) {
|
||||
const transport = this.deps.transports[target.provider];
|
||||
if (!transport) {
|
||||
delivery.set(target.connectionId, { connectionId: target.connectionId, provider: target.provider, status: "unsupported", error: "no transport configured" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await transport.send({ connectionId: target.connectionId, liveRemoteId: target.liveRemoteId, text: request.text, replyTo: parent?.connectionId === target.connectionId ? parent.providerMessageId : undefined });
|
||||
outcomes.set(target.connectionId, result);
|
||||
delivery.set(target.connectionId, { connectionId: target.connectionId, provider: target.provider, status: "sent" });
|
||||
} catch (error) {
|
||||
delivery.set(target.connectionId, { connectionId: target.connectionId, provider: target.provider, status: "failed", error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
// Store our own message once (echoed to every viewer).
|
||||
const primary = accepted.find(t => delivery.get(t.connectionId)?.status === "sent");
|
||||
if (!primary) return { message: null, delivery: [...delivery.values()], refused: { reason: "no transport accepted the send", state: "degraded" } };
|
||||
const primaryResult = outcomes.get(primary.connectionId);
|
||||
if (!primaryResult) throw new Error("missing primary delivery result");
|
||||
const stored = await this.ingest(workspaceId, [{
|
||||
provider: primary.provider,
|
||||
connectionId: primary.connectionId,
|
||||
channelId,
|
||||
providerMessageId: primaryResult.providerMessageId,
|
||||
author: request.author,
|
||||
text: request.text,
|
||||
sentAt: primaryResult.sentAt,
|
||||
receivedAt: this.now(),
|
||||
...(request.replyTo === undefined ? {} : { replyTo: request.replyTo }),
|
||||
source: "live-chat",
|
||||
}]).then(result => result.inserted[0] ?? null);
|
||||
const deliveryRows = [...delivery.values()];
|
||||
if (stored) {
|
||||
await this.deps.publish(workspaceId, channelId, {
|
||||
kind: "chat", liveStreamId: channelId, channelId, seq: await this.nextSeq(workspaceId, channelId), messageId: stored.id, delivery: deliveryRows,
|
||||
});
|
||||
}
|
||||
return { message: stored, delivery: deliveryRows };
|
||||
}
|
||||
|
||||
/** Moderation fan-out: delete/timeout/ban on every capable target; the local
|
||||
* row is flagged so the panel drops it immediately. */
|
||||
async moderate(workspaceId: string, channelId: string, request: {
|
||||
action: "delete" | "timeout" | "ban"; durationSeconds?: number;
|
||||
message: { id?: string; connectionId?: string; provider: string; providerMessageId: string; authorHandle: string };
|
||||
}): Promise<ModerateOutcome> {
|
||||
const targets = await this.deps.targets(workspaceId, channelId);
|
||||
const delivery: Delivery[] = [];
|
||||
for (const target of targets) {
|
||||
if (!target.moderate) { delivery.push({ connectionId: target.connectionId, provider: target.provider, status: "unsupported", error: target.reason ?? "moderation not supported" }); continue; }
|
||||
if (target.provider !== request.message.provider || (request.message.connectionId && target.connectionId !== request.message.connectionId)) {
|
||||
delivery.push({ connectionId: target.connectionId, provider: target.provider, status: "unsupported", error: "message belongs to another target" }); continue;
|
||||
}
|
||||
const verdict = await Promise.resolve().then(() => this.deps.governors.take(target.provider)).catch(() => ({ allowed: false, state: "degraded" as const, reason: "governor unavailable" }));
|
||||
if (!verdict.allowed) { delivery.push({ connectionId: target.connectionId, provider: target.provider, status: "unsupported", error: verdict.reason }); continue; }
|
||||
const transport = this.deps.transports[target.provider];
|
||||
if (!transport) { delivery.push({ connectionId: target.connectionId, provider: target.provider, status: "unsupported", error: "no transport configured" }); continue; }
|
||||
try {
|
||||
await transport.moderate({ connectionId: target.connectionId, liveRemoteId: target.liveRemoteId,
|
||||
messageRemoteId: request.message.providerMessageId, authorHandle: request.message.authorHandle, action: request.action, ...(request.durationSeconds === undefined ? {} : { durationSeconds: request.durationSeconds }) });
|
||||
delivery.push({ connectionId: target.connectionId, provider: target.provider, status: "sent" });
|
||||
} catch (error) {
|
||||
delivery.push({ connectionId: target.connectionId, provider: target.provider, status: "failed", error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
if (request.action === "delete" && delivery.some(d => d.status === "sent")) {
|
||||
await this.deps.transaction(workspaceId, async client => {
|
||||
await client.query(
|
||||
"UPDATE live_chat_messages SET is_moderated = true WHERE workspace_id=$1 AND channel_id=$2 AND provider=$3 AND provider_msg_id=$4",
|
||||
[workspaceId, channelId, request.message.provider, request.message.providerMessageId]);
|
||||
});
|
||||
}
|
||||
if (delivery.some(row => row.status === "sent")) await this.deps.publish(workspaceId, channelId, {
|
||||
kind: "chat", liveStreamId: channelId, channelId, seq: await this.nextSeq(workspaceId, channelId),
|
||||
action: request.action, targetMessageId: request.message.id ?? request.message.providerMessageId, targetHandle: request.message.authorHandle,
|
||||
});
|
||||
return { delivery };
|
||||
}
|
||||
}
|
||||
77
apps/api/src/live-chat/live-chat.units.test.ts
Normal file
77
apps/api/src/live-chat/live-chat.units.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { authorColor } from "@tactical-media-broadcast-hub/core";
|
||||
import { ChatRingBuffer } from "./ring-buffer.js";
|
||||
import { SendGovernors, TokenBucket } from "./governors.js";
|
||||
import type { StoredChatMessage } from "./types.js";
|
||||
|
||||
describe("send governors", () => {
|
||||
it("token bucket refuses beyond burst and refills over time", () => {
|
||||
let t = 0;
|
||||
const bucket = new TokenBucket({ capacity: 2, refillPerSecond: 1, now: () => t });
|
||||
expect(bucket.tryTake()).toBe(true); expect(bucket.tryTake()).toBe(true);
|
||||
expect(bucket.tryTake()).toBe(false);
|
||||
t += 1000; expect(bucket.tryTake()).toBe(true);
|
||||
});
|
||||
it("quota ledger refuses a send below budget and reports paused:quota", () => {
|
||||
const governors = new SendGovernors({ quotas: { youtube: { budget: 50, costPerCall: 50 } } });
|
||||
expect(governors.take("youtube")).toMatchObject({ allowed: true, state: "ok" }); // insert: 50 of 50 units
|
||||
expect(governors.take("youtube")).toMatchObject({ allowed: false, state: "paused:quota", reason: expect.stringContaining("quota") });
|
||||
expect(governors.probe("youtube").state).toBe("paused:quota");
|
||||
});
|
||||
it("rate exhaustion surfaces paused:rate", () => {
|
||||
const governors = new SendGovernors({ buckets: { twitch: { capacity: 1, refillPerSecond: 0.001 } } });
|
||||
expect(governors.take("twitch").allowed).toBe(true);
|
||||
expect(governors.take("twitch")).toMatchObject({ allowed: false, state: "paused:rate" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ring buffer", () => {
|
||||
it("serves resume from the window and demands resync past its edge", () => {
|
||||
const buffer = new ChatRingBuffer(4);
|
||||
for (let seq = 1; seq <= 12; seq += 1) {
|
||||
buffer.push({ ...message(seq), seq });
|
||||
}
|
||||
// The advertised capacity and the resumable window agree.
|
||||
expect(buffer.latest("live-1", 100)).toHaveLength(4);
|
||||
expect(buffer.latest("live-1", 100).at(0)?.seq).toBe(9);
|
||||
expect(buffer.after("live-1", 10)).toHaveLength(2);
|
||||
expect(buffer.canResume("live-1", 8)).toBe(true); // everything >8 is retained
|
||||
expect(buffer.canResume("live-1", 7)).toBe(false); // fell out of the window
|
||||
expect(buffer.canResume("live-1", 99)).toBe(false); // ahead of the hub
|
||||
});
|
||||
});
|
||||
|
||||
function message(seq: number): StoredChatMessage {
|
||||
return {
|
||||
id: `m-${seq}`, seq, liveStreamId: "live-1", connectionId: "conn-1", provider: "mock-livestream",
|
||||
providerMessageId: `pm-${seq}`, author: { handle: `viewer-${seq}`, color: authorColor("mock-livestream", `viewer-${seq}`) },
|
||||
text: `hello ${seq}`, sentAt: new Date(0).toISOString(), receivedAt: new Date(0).toISOString(),
|
||||
isModerated: false, source: "live-chat",
|
||||
};
|
||||
}
|
||||
|
||||
describe("author identity colour", () => {
|
||||
it("is a pure function of provider:handle", () => {
|
||||
expect(authorColor("twitch", "ada")).toBe(authorColor("twitch", "ada"));
|
||||
expect(authorColor("twitch", "ada")).not.toBe(authorColor("youtube", "ada"));
|
||||
expect(authorColor("twitch", "ada")).toMatch(/^hsl\(\d+ 70% 62%\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("daily quota rollover", () => {
|
||||
it("resets on the next UTC day without replacing the ledger", () => {
|
||||
let now = Date.parse("2026-10-08T23:59:59Z");
|
||||
const governors = new SendGovernors({ quotas: { youtube: { budget: 50, costPerCall: 50 } }, now: () => now });
|
||||
expect(governors.take("youtube").allowed).toBe(true);
|
||||
expect(governors.take("youtube").allowed).toBe(false);
|
||||
now += 1000;
|
||||
expect(governors.take("youtube").allowed).toBe(true);
|
||||
});
|
||||
it("does not charge quota when a token bucket refuses the send", () => {
|
||||
const governors = new SendGovernors({ quotas: { youtube: { budget: 100, costPerCall: 50 } }, buckets: { youtube: { capacity: 1, refillPerSecond: 1 } }, now: () => 0 });
|
||||
expect(governors.take("youtube").allowed).toBe(true);
|
||||
expect(governors.take("youtube").state).toBe("paused:rate");
|
||||
expect(governors.probe("youtube").state).toBe("paused:rate");
|
||||
});
|
||||
});
|
||||
91
apps/api/src/live-chat/mock-driver.ts
Normal file
91
apps/api/src/live-chat/mock-driver.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Mock-livestream ingestion driver: the deterministic contract/E2E harness
|
||||
* (§3.2). Pulls `getLiveChat` pages from the mock connector and feeds the hub
|
||||
* with normalized events; the connector's own dedupe-free buffer is the
|
||||
* "provider", so replaying a page is exactly the dedupe test.
|
||||
*/
|
||||
import type { PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import type { ConnectorConnection, LiveChatMessage } from "@tactical-media-broadcast-hub/core";
|
||||
import { authorColor } from "@tactical-media-broadcast-hub/core";
|
||||
import type { ChatIngestDriver, IngestHandler, NormalizedChatEvent } from "./types.js";
|
||||
|
||||
export interface MockDriverOptions {
|
||||
workspaceId: string;
|
||||
connectionId: string;
|
||||
channelId: string;
|
||||
liveRemoteId: string;
|
||||
/** Poll interval; 0 disables the timer (tests call `poll()` by hand). */
|
||||
intervalMs?: number;
|
||||
onError?: (error: unknown) => void;
|
||||
commentsOnly?: boolean;
|
||||
connection?: ConnectorConnection;
|
||||
pageSize?: number;
|
||||
schedule?: (callback: () => void, delayMs: number) => ReturnType<typeof setInterval>;
|
||||
cancel?: (timer: ReturnType<typeof setInterval>) => void;
|
||||
}
|
||||
|
||||
export class MockLivestreamDriver implements ChatIngestDriver {
|
||||
readonly provider: string;
|
||||
private handler: IngestHandler | null = null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private cursor: string | undefined;
|
||||
private stopped = false;
|
||||
private inFlight: Promise<NormalizedChatEvent[]> | null = null;
|
||||
constructor(private readonly connector: PlatformConnector, private readonly options: MockDriverOptions) { this.provider = connector.manifest().id; }
|
||||
async start(handler: IngestHandler): Promise<void> {
|
||||
await this.stop();
|
||||
this.handler = handler;
|
||||
this.stopped = false;
|
||||
const intervalMs = this.options.intervalMs ?? 0;
|
||||
if (intervalMs > 0) {
|
||||
const schedule = this.options.schedule ?? ((cb, ms) => setInterval(cb, ms));
|
||||
this.timer = schedule(() => { void this.poll().catch(error => this.options.onError?.(error)); }, intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
await this.poll();
|
||||
}
|
||||
/** One poll cycle → hub. Returns the events produced. */
|
||||
poll(): Promise<NormalizedChatEvent[]> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
this.inFlight = this.pollOnce().finally(() => { this.inFlight = null; });
|
||||
return this.inFlight;
|
||||
}
|
||||
private async pollOnce(): Promise<NormalizedChatEvent[]> {
|
||||
if (!this.handler || this.stopped) return [];
|
||||
const connection = this.options.connection ?? { id: this.options.connectionId, workspaceId: this.options.workspaceId, provider: this.provider,
|
||||
channelId: this.options.channelId, credentialId: { kind: "credential-ref" as const, id: "mock" }, fields: {}, createdAt: new Date() };
|
||||
const page = this.options.commentsOnly ? await this.connector.getComments?.(connection, { onRemoteId: this.options.liveRemoteId, cursor: this.cursor, limit: this.options.pageSize ?? 100 }).then(page => ({
|
||||
...page, items: page.items.map(comment => ({ id: comment.id, provider: comment.provider, liveStreamId: this.options.liveRemoteId, authorHandle: comment.authorHandle, text: comment.text, sentAt: comment.createdAt, isModerated: false })),
|
||||
})) : await this.connector.getLiveChat?.(
|
||||
connection,
|
||||
{ liveRemoteId: this.options.liveRemoteId, cursor: this.cursor, limit: this.options.pageSize ?? 100 },
|
||||
);
|
||||
if (!page || this.stopped || !this.handler) return [];
|
||||
const events = page.items.map((message: LiveChatMessage) => ({ ...toEvent(this.options, message), source: this.options.commentsOnly ? "social-reply" as const : "live-chat" as const }));
|
||||
if (events.length > 0) await this.handler(this.options.workspaceId, events);
|
||||
this.cursor = page.nextCursor;
|
||||
return events;
|
||||
}
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.timer) { (this.options.cancel ?? clearInterval)(this.timer); this.timer = null; }
|
||||
this.handler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a connector `LiveChatMessage` to the hub's normalized event. */
|
||||
export function toEvent(options: Pick<MockDriverOptions, "connectionId" | "channelId">, message: LiveChatMessage): NormalizedChatEvent {
|
||||
return {
|
||||
provider: message.provider,
|
||||
connectionId: options.connectionId,
|
||||
channelId: options.channelId,
|
||||
providerMessageId: message.id,
|
||||
author: { handle: message.authorHandle },
|
||||
text: message.text,
|
||||
sentAt: message.sentAt,
|
||||
receivedAt: new Date(),
|
||||
source: "live-chat",
|
||||
};
|
||||
}
|
||||
|
||||
export const mockAuthorColor = authorColor;
|
||||
41
apps/api/src/live-chat/redis-governors.ts
Normal file
41
apps/api/src/live-chat/redis-governors.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { OnModuleDestroy } from "@nestjs/common";
|
||||
import { RespControlRedis, type ControlRedis } from "../media-control/redis.js";
|
||||
import type { GovernorVerdict, SendGovernorOptions } from "./governors.js";
|
||||
|
||||
export interface ChatGovernors {
|
||||
take(provider: string, cost?: number): GovernorVerdict | Promise<GovernorVerdict>;
|
||||
probe(provider: string): GovernorVerdict | Promise<GovernorVerdict>;
|
||||
}
|
||||
/** One atomic check/charge for every API instance. UTC-day keys survive restarts. */
|
||||
export const CHAT_GOVERNOR_SCRIPT = `
|
||||
local now, capacity, refill, budget, cost, consume = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4]), tonumber(ARGV[5]), tonumber(ARGV[6])
|
||||
local spent = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if budget >= 0 and spent + cost > budget then return {0, 'paused:quota', 'daily quota exhausted'} end
|
||||
local tokens = capacity
|
||||
if capacity > 0 then
|
||||
local old = redis.call('HMGET', KEYS[2], 'tokens', 'at')
|
||||
if old[1] then tokens = math.min(capacity, tonumber(old[1]) + math.max(0, now - tonumber(old[2])) * refill / 1000) end
|
||||
if tokens < 1 then return {0, 'paused:rate', 'send rate budget exhausted'} end
|
||||
end
|
||||
if consume == 1 then
|
||||
if capacity > 0 then redis.call('HSET', KEYS[2], 'tokens', tokens-1, 'at', now); redis.call('EXPIRE', KEYS[2], 172800) end
|
||||
if budget >= 0 then redis.call('INCRBYFLOAT', KEYS[1], cost); redis.call('EXPIRE', KEYS[1], 172800) end
|
||||
end
|
||||
return {1, 'ok', ''}`;
|
||||
export class RedisChatGovernors implements ChatGovernors, OnModuleDestroy {
|
||||
constructor(private readonly options: SendGovernorOptions, private readonly redis: ControlRedis = new RespControlRedis(process.env.REDIS_URL ?? "redis://localhost:6379")) {}
|
||||
take(provider: string, cost?: number): Promise<GovernorVerdict> { return this.check(provider, true, cost); }
|
||||
probe(provider: string): Promise<GovernorVerdict> { return this.check(provider, false); }
|
||||
private async check(provider: string, consume: boolean, cost?: number): Promise<GovernorVerdict> {
|
||||
const bucket = this.options.buckets?.[provider], quota = this.options.quotas?.[provider];
|
||||
if (!bucket && !quota) return { allowed: true, state: "ok" };
|
||||
const now = this.options.now?.() ?? Date.now(), day = new Date(now).toISOString().slice(0,10), key = encodeURIComponent(provider);
|
||||
const result = await this.redis.eval(CHAT_GOVERNOR_SCRIPT, [`chat:quota:${key}:${day}`, `chat:rate:${key}`],
|
||||
[String(now), String(bucket?.capacity ?? 0), String(bucket?.refillPerSecond ?? 0), String(quota?.budget ?? -1), String(cost ?? quota?.costPerCall ?? 1), consume ? "1" : "0"]);
|
||||
if (!Array.isArray(result)) throw new Error("invalid governor response");
|
||||
const allowed = result[0] === 1, state = result[1];
|
||||
if (state !== "ok" && state !== "paused:rate" && state !== "paused:quota") throw new Error("invalid governor state");
|
||||
return { allowed, state, ...(!allowed ? { reason: String(result[2]) } : {}) };
|
||||
}
|
||||
onModuleDestroy(): void { this.redis.close(); }
|
||||
}
|
||||
49
apps/api/src/live-chat/ring-buffer.ts
Normal file
49
apps/api/src/live-chat/ring-buffer.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Bounded latest-message window per channel ("ring buffer", §3.3): the hub
|
||||
* keeps the last N stored messages so a `lastSeq` resume can be served
|
||||
* in-process, and the fallback REST cursor path reads the same window.
|
||||
*
|
||||
* The public resume window is exactly N messages, including at eviction edges.
|
||||
*/
|
||||
import type { StoredChatMessage } from "./types.js";
|
||||
|
||||
export class ChatRingBuffer {
|
||||
private readonly map = new Map<string, StoredChatMessage[]>();
|
||||
constructor(private readonly capacity: number = 500) {
|
||||
if (!Number.isInteger(capacity) || capacity < 1) throw new Error("ring buffer capacity must be a positive integer");
|
||||
}
|
||||
push(message: StoredChatMessage): void {
|
||||
const key = `${message.liveStreamId}`;
|
||||
const items = this.map.get(key) ?? [];
|
||||
items.push(message);
|
||||
if (items.length > this.capacity) items.splice(0, items.length - this.capacity);
|
||||
this.map.set(key, items);
|
||||
}
|
||||
/** Latest `limit` messages, oldest first. */
|
||||
latest(liveStreamId: string, limit = this.capacity): StoredChatMessage[] {
|
||||
const items = this.map.get(liveStreamId) ?? [];
|
||||
return items.slice(Math.max(0, items.length - limit));
|
||||
}
|
||||
/** Messages strictly after `after`, oldest first, up to `limit`. */
|
||||
after(liveStreamId: string, after: number, limit = this.capacity): StoredChatMessage[] {
|
||||
return (this.map.get(liveStreamId) ?? []).filter(m => m.seq > after).slice(0, limit);
|
||||
}
|
||||
/** Oldest retained seq; undefined when the window is empty. */
|
||||
oldestSeq(liveStreamId: string): number | undefined {
|
||||
return this.map.get(liveStreamId)?.[0]?.seq;
|
||||
}
|
||||
highestSeq(liveStreamId: string): number {
|
||||
return this.latest(liveStreamId, 1)[0]?.seq ?? 0;
|
||||
}
|
||||
/** Can a resume from `lastSeq` be served from the window? */
|
||||
canResume(liveStreamId: string, lastSeq: number): boolean {
|
||||
if (lastSeq < 0) return false;
|
||||
const items = this.map.get(liveStreamId) ?? [];
|
||||
const highest = items.at(-1)?.seq ?? 0;
|
||||
if (lastSeq > highest) return false;
|
||||
const oldest = items[0]?.seq ?? highest + 1;
|
||||
return lastSeq >= oldest - 1;
|
||||
}
|
||||
drop(liveStreamId: string): void { this.map.delete(liveStreamId); }
|
||||
get size(): number { return this.map.size; }
|
||||
}
|
||||
17
apps/api/src/live-chat/runtime.ts
Normal file
17
apps/api/src/live-chat/runtime.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Inject, Injectable, Optional, OnApplicationBootstrap, OnModuleDestroy } from "@nestjs/common";
|
||||
import type { LiveChatDependencies } from "./live-chat.service.js";
|
||||
import { LiveChatHubService } from "./live-chat.service.js";
|
||||
import type { ChatIngestDriver } from "./types.js";
|
||||
|
||||
export const LIVE_CHAT_RUNTIME = Symbol("LIVE_CHAT_RUNTIME");
|
||||
export type LiveChatRuntimeOptions = Partial<Pick<LiveChatDependencies, "targets" | "transports" | "filters">> & { drivers?: ChatIngestDriver[] };
|
||||
@Injectable()
|
||||
export class LiveChatRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
constructor(@Inject(LiveChatHubService) private readonly hub: LiveChatHubService,
|
||||
@Optional() @Inject(LIVE_CHAT_RUNTIME) private readonly options?: LiveChatRuntimeOptions) {}
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try { for (const driver of this.options?.drivers ?? []) await driver.start((workspaceId, events) => this.hub.ingest(workspaceId, events)); }
|
||||
catch (error) { await this.onModuleDestroy(); throw error; }
|
||||
}
|
||||
async onModuleDestroy(): Promise<void> { await Promise.all((this.options?.drivers ?? []).map(driver => driver.stop())); }
|
||||
}
|
||||
49
apps/api/src/live-chat/shared-poller.ts
Normal file
49
apps/api/src/live-chat/shared-poller.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { IngestHandler, NormalizedChatEvent } from "./types.js";
|
||||
import type { ChatGovernors } from "./redis-governors.js";
|
||||
|
||||
export interface SharedChatPollerOptions {
|
||||
workspaceId: string;
|
||||
broadcastId: string;
|
||||
provider: string;
|
||||
fetch: (cursor?: string) => Promise<{ events: NormalizedChatEvent[]; nextPageToken?: string; pollingIntervalMillis: number }>;
|
||||
ingest: IngestHandler;
|
||||
governors: ChatGovernors;
|
||||
paused?: (reason: string) => void;
|
||||
}
|
||||
/** One poll chain per workspace/broadcast. Ref-counted consumers share list quota. */
|
||||
export class SharedChatPollers {
|
||||
private readonly entries = new Map<string, { refs: number; stopped: boolean; timer: ReturnType<typeof setTimeout> | null }>();
|
||||
acquire(options: SharedChatPollerOptions): () => void {
|
||||
const key = `${options.workspaceId}:${options.broadcastId}`;
|
||||
const existing = this.entries.get(key);
|
||||
if (existing) { existing.refs += 1; return this.releaseOnce(key); }
|
||||
const entry = { refs: 1, stopped: false, timer: null as ReturnType<typeof setTimeout> | null };
|
||||
this.entries.set(key, entry);
|
||||
let cursor: string | undefined;
|
||||
const poll = async (): Promise<void> => {
|
||||
if (entry.stopped) return;
|
||||
let delay = 1000;
|
||||
const verdict = await options.governors.take(options.provider, 1);
|
||||
if (!verdict.allowed) { options.paused?.(verdict.reason ?? "polling paused"); delay = 60_000; }
|
||||
else {
|
||||
try {
|
||||
const page = await options.fetch(cursor);
|
||||
if (entry.stopped) return;
|
||||
await options.ingest(options.workspaceId, page.events);
|
||||
cursor = page.nextPageToken;
|
||||
delay = Math.max(1, page.pollingIntervalMillis);
|
||||
} catch (error) { options.paused?.(error instanceof Error ? error.message : "polling failed"); }
|
||||
}
|
||||
if (!entry.stopped) { entry.timer = setTimeout(() => { void poll(); }, delay); entry.timer.unref(); }
|
||||
};
|
||||
void poll();
|
||||
return this.releaseOnce(key);
|
||||
}
|
||||
private releaseOnce(key: string): () => void { let active = true; return () => { if (active) { active = false; this.release(key); } }; }
|
||||
private release(key: string): void {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry || --entry.refs > 0) return;
|
||||
entry.stopped = true; if (entry.timer) clearTimeout(entry.timer); this.entries.delete(key);
|
||||
}
|
||||
close(): void { for (const [key, entry] of this.entries) { entry.refs = 1; this.release(key); } }
|
||||
}
|
||||
103
apps/api/src/live-chat/twitch-adapter.ts
Normal file
103
apps/api/src/live-chat/twitch-adapter.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Twitch adapter: maps the connector's existing chat event shape into the
|
||||
* hub's normalized event (§3.2 read side), and wraps Helix send/moderate
|
||||
* behind the `ChatTransport` port so the hub never branches on a provider.
|
||||
*
|
||||
* The connector buffers EventSub `channel.chat.message` notifications for
|
||||
* `getLiveChat`; that buffer is the read path here. Send/moderate go through
|
||||
* the connector's `sendLiveChatMessage`/`moderateLiveChat` when present, or an
|
||||
* injected Helix transport (contract-tested with mocks — no real creds).
|
||||
*/
|
||||
import type { PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { MockLivestreamDriver } from "./mock-driver.js";
|
||||
import type { ConnectorConnection, LiveChatMessage } from "@tactical-media-broadcast-hub/core";
|
||||
import { CapabilityNotSupportedError } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import type { ChatTransport, NormalizedChatEvent } from "./types.js";
|
||||
|
||||
export interface TwitchAdapterOptions {
|
||||
workspaceId: string;
|
||||
connectionId: string;
|
||||
channelId: string;
|
||||
/** Provider's live (stream) id — the connector's `liveStreamId`. */
|
||||
liveRemoteId: string;
|
||||
}
|
||||
|
||||
/** Map a Twitch-shaped `LiveChatMessage` from the connector into the hub event. */
|
||||
export function twitchToEvent(options: TwitchAdapterOptions, message: LiveChatMessage): NormalizedChatEvent {
|
||||
return {
|
||||
provider: message.provider,
|
||||
connectionId: options.connectionId,
|
||||
channelId: options.channelId,
|
||||
providerMessageId: message.id,
|
||||
author: { handle: message.authorHandle },
|
||||
text: message.text,
|
||||
sentAt: message.sentAt,
|
||||
receivedAt: new Date(),
|
||||
source: "live-chat",
|
||||
};
|
||||
}
|
||||
|
||||
export interface HelixChatTransport {
|
||||
/** POST /helix/chat/messages (or an injected fake in tests). */
|
||||
send(request: { connectionId: string; liveRemoteId: string; text: string; replyTo?: string }):
|
||||
Promise<{ providerMessageId: string; sentAt: Date }>;
|
||||
/** DELETE /helix/moderation/chat, timeout/ban endpoints. */
|
||||
moderate(request: {
|
||||
connectionId: string;
|
||||
liveRemoteId: string;
|
||||
messageRemoteId: string;
|
||||
authorHandle?: string;
|
||||
action: "delete" | "timeout" | "ban";
|
||||
durationSeconds?: number;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitch send/moderate transport. Prefers an injected Helix implementation
|
||||
* (real creds, S5 phase 1 wiring); otherwise it delegates to the connector's
|
||||
* own methods and surfaces `unsupported` when the connector declares the
|
||||
* capability absent (its IRC adapter is not configured).
|
||||
*/
|
||||
export class TwitchChatTransport implements ChatTransport {
|
||||
readonly provider: string;
|
||||
constructor(private readonly connector: PlatformConnector, private readonly helix?: HelixChatTransport,
|
||||
private readonly resolveConnection?: (connectionId: string) => Promise<ConnectorConnection>) {
|
||||
this.provider = connector.manifest().id;
|
||||
}
|
||||
private async connection(connectionId: string): Promise<ConnectorConnection> {
|
||||
if (!this.resolveConnection) throw new Error("chat adapter requires a connection resolver");
|
||||
return this.resolveConnection(connectionId);
|
||||
}
|
||||
async send(request: { connectionId: string; liveRemoteId: string; text: string; replyTo?: string }):
|
||||
Promise<{ providerMessageId: string; sentAt: Date }> {
|
||||
if (this.helix) return this.helix.send(request);
|
||||
if (request.replyTo) throw new CapabilityNotSupportedError(this.provider, "threaded reply requires Helix transport");
|
||||
if (this.connector.sendLiveChatMessage) {
|
||||
const sent = await this.connector.sendLiveChatMessage(await this.connection(request.connectionId), { liveRemoteId: request.liveRemoteId, text: request.text });
|
||||
return { providerMessageId: sent.id, sentAt: sent.sentAt };
|
||||
}
|
||||
throw new CapabilityNotSupportedError(this.provider, "chat send");
|
||||
}
|
||||
async moderate(request: {
|
||||
connectionId: string; liveRemoteId: string; messageRemoteId: string;
|
||||
authorHandle?: string;
|
||||
action: "delete" | "timeout" | "ban"; durationSeconds?: number;
|
||||
}): Promise<void> {
|
||||
if (this.helix) return this.helix.moderate(request);
|
||||
if (request.action === "timeout") throw new CapabilityNotSupportedError(this.provider, "timed moderation requires Helix transport");
|
||||
if (this.connector.moderateLiveChat) {
|
||||
await this.connector.moderateLiveChat(await this.connection(request.connectionId), {
|
||||
liveRemoteId: request.liveRemoteId, messageRemoteId: request.messageRemoteId, action: request.action,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new CapabilityNotSupportedError(this.provider, "chat moderation");
|
||||
}
|
||||
}
|
||||
|
||||
/** The existing EventSub read buffer is consumed through the shared driver. */
|
||||
export class TwitchChatDriver extends MockLivestreamDriver {
|
||||
constructor(connector: PlatformConnector, options: TwitchAdapterOptions, connection: ConnectorConnection) {
|
||||
super(connector, { ...options, connection, intervalMs: 1000 });
|
||||
}
|
||||
}
|
||||
108
apps/api/src/live-chat/types.ts
Normal file
108
apps/api/src/live-chat/types.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* S5 chat hub — internal ports and normalized shapes.
|
||||
*
|
||||
* The wire contract itself lives in `@tactical-media-broadcast-hub/core` (chat-contract.ts)
|
||||
* and is frozen. This module is the hub implementation: ingestion, sequencing,
|
||||
* fan-out, send governors, device pairing.
|
||||
*/
|
||||
import type { Delivery, LiveChatCapability, LiveChatMessage, Page } from "@tactical-media-broadcast-hub/core";
|
||||
|
||||
/** A provider chat event normalized at the connector boundary. */
|
||||
export interface NormalizedChatEvent {
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
channelId: string;
|
||||
/** Provider's own message id — the dedupe key with (workspace, provider). */
|
||||
providerMessageId: string;
|
||||
author: { handle: string; isBot?: boolean; displayName?: string; avatarUrl?: string; badges?: string[] };
|
||||
text: string;
|
||||
sentAt: Date;
|
||||
receivedAt?: Date;
|
||||
replyTo?: string;
|
||||
source?: "live-chat" | "social-reply" | "system";
|
||||
}
|
||||
|
||||
/** Row written by the hub, already in wire shape. */
|
||||
export interface StoredChatMessage {
|
||||
id: string;
|
||||
seq: number;
|
||||
liveStreamId: string;
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
providerMessageId: string;
|
||||
author: { handle: string; displayName?: string; avatarUrl?: string; badges?: string[]; color: string };
|
||||
text: string;
|
||||
sentAt: string;
|
||||
receivedAt: string;
|
||||
isModerated: boolean;
|
||||
replyTo?: string;
|
||||
source: "live-chat" | "social-reply" | "system";
|
||||
}
|
||||
|
||||
/** Result of ingesting one batch: only the rows actually inserted. */
|
||||
export interface IngestResult {
|
||||
inserted: StoredChatMessage[];
|
||||
/** Provider ids already present (dedupe hits) — never re-published. */
|
||||
duplicates: string[];
|
||||
}
|
||||
|
||||
export interface MessageQuery {
|
||||
after?: number;
|
||||
limit: number;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface MessagePage {
|
||||
items: StoredChatMessage[];
|
||||
nextCursor?: number;
|
||||
}
|
||||
|
||||
export type IngestHandler = (workspaceId: string, events: NormalizedChatEvent[]) => Promise<IngestResult>;
|
||||
|
||||
/** Ingestion driver: a platform connection feeding the hub. */
|
||||
export interface ChatIngestDriver {
|
||||
readonly provider: string;
|
||||
start(handler: IngestHandler): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Per-target send/moderate transport (Helix for Twitch, mock for tests). */
|
||||
export interface ChatTransport {
|
||||
readonly provider: string;
|
||||
send(request: { connectionId: string; liveRemoteId: string; text: string; replyTo?: string }):
|
||||
Promise<{ providerMessageId: string; sentAt: Date }>;
|
||||
moderate(request: {
|
||||
connectionId: string;
|
||||
liveRemoteId: string;
|
||||
messageRemoteId: string;
|
||||
authorHandle?: string;
|
||||
action: "delete" | "timeout" | "ban";
|
||||
durationSeconds?: number;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
/** A capability-resolved target for a reply/moderation fan-out. */
|
||||
export interface ChatTarget {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
liveRemoteId: string;
|
||||
read: boolean;
|
||||
reply: boolean;
|
||||
moderate: boolean;
|
||||
state: LiveChatCapability["state"];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SendOutcome {
|
||||
message: StoredChatMessage | null;
|
||||
delivery: Delivery[];
|
||||
/** Refused before any transport call (governor or no capable target). */
|
||||
refused?: { reason: string; state: LiveChatCapability["state"] };
|
||||
}
|
||||
|
||||
export interface ModerateOutcome {
|
||||
delivery: Delivery[];
|
||||
}
|
||||
|
||||
/** Connector chat page shape (`PlatformConnector.getLiveChat`). */
|
||||
export type ConnectorChatPage = Page<LiveChatMessage>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue