Tactical Media Broadcast Hub — initial release

Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,17 @@
{
"name": "@tactical-media-broadcast-hub/core",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "^5.5.4",
"vitest": "^2.0.5"
}
}

View file

@ -0,0 +1,49 @@
/**
* Declarative connector capabilities. The UI renders from these flags:
* an undeclared capability renders nothing, never a grayed-out lie.
*/
export interface ConnectorCapabilities {
publishText?: boolean;
publishImages?: boolean;
publishCarousel?: boolean;
publishVideo?: boolean;
shortFormVideo?: boolean;
longFormVideo?: boolean;
videoThumbnails?: boolean;
videoCaptions?: boolean;
playlists?: boolean;
feed?: boolean;
mentions?: boolean;
comments?: boolean;
replies?: boolean;
reactions?: boolean;
directMessages?: boolean;
/** Amplification: repost/boost/retweet an existing remote item. */
amplifyRepost?: boolean;
/** Amplification: quote-post / reshare with commentary. */
amplifyQuote?: boolean;
/** Amplification: share to story / crosspost variants. */
amplifyShare?: boolean;
livestreams?: boolean;
createLivestream?: boolean;
manageLivestream?: boolean;
livePlaybackSource?: boolean;
liveChatRead?: boolean;
liveChatWrite?: boolean;
liveChatModeration?: boolean;
analytics?: boolean;
videoAnalytics?: boolean;
livestreamAnalytics?: boolean;
webhooks?: boolean;
nativeScheduling?: boolean;
deletePublication?: boolean;
}

View file

@ -0,0 +1,201 @@
/**
* Handwritten JSON Schema for the frozen S5 chat contract (§3.5).
*
* Written by hand rather than generated so the schema stays reviewable and the
* package gains no dependency; `chat-contract.test.ts` proves the schema
* accepts every type-shaped fixture and rejects the mutations that matter.
* Clients (Opticast S5b) validate a recorded WS transcript against these.
*/
import type { ChatMessage, Delivery, LiveChatCapability } from "./chat-contract.js";
export const CHAT_MESSAGE_SCHEMA = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://tactical-media-broadcast-hub.dev/schemas/chat-message.json",
title: "ChatMessage",
type: "object",
additionalProperties: false,
required: [
"seq",
"id",
"liveStreamId",
"connectionId",
"provider",
"providerMessageId",
"author",
"text",
"sentAt",
"receivedAt",
"isModerated",
"source",
],
properties: {
seq: { type: "integer", minimum: 1 },
id: { type: "string", minLength: 1 },
liveStreamId: { type: "string", minLength: 1 },
connectionId: { type: "string", minLength: 1 },
provider: { type: "string", minLength: 1 },
providerMessageId: { type: "string", minLength: 1 },
author: {
type: "object",
additionalProperties: false,
required: ["handle", "color"],
properties: {
handle: { type: "string", minLength: 1 },
displayName: { type: "string" },
avatarUrl: { type: "string" },
badges: { type: "array", items: { type: "string" } },
color: { type: "string", pattern: "^(hsl\\(|#|rgb\\()[^)]*\\)?" },
},
},
text: { type: "string", minLength: 0, maxLength: 5000 },
sentAt: { type: "string", format: "date-time" },
receivedAt: { type: "string", format: "date-time" },
isModerated: { type: "boolean" },
replyTo: { type: "string" },
source: { enum: ["live-chat", "social-reply", "system"] },
},
} as const;
export const DELIVERY_SCHEMA = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://tactical-media-broadcast-hub.dev/schemas/delivery.json",
title: "Delivery",
type: "object",
additionalProperties: false,
required: ["connectionId", "provider", "status"],
properties: {
connectionId: { type: "string", minLength: 1 },
provider: { type: "string", minLength: 1 },
status: { enum: ["queued", "sent", "failed", "unsupported"] },
error: { type: "string" },
},
} as const;
export const LIVE_CHAT_CAPABILITY_SCHEMA = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://tactical-media-broadcast-hub.dev/schemas/live-chat-capability.json",
title: "LiveChatCapability",
type: "object",
additionalProperties: false,
required: ["connectionId", "provider", "read", "reply", "moderate", "state"],
properties: {
connectionId: { type: "string", minLength: 1 },
provider: { type: "string", minLength: 1 },
read: { type: "boolean" },
reply: { type: "boolean" },
moderate: { type: "boolean" },
state: { enum: ["ok", "paused:quota", "paused:rate", "degraded"] },
reason: { type: "string" },
},
} as const;
/** A `msg` frame payload on the livechat topic. */
export const CHAT_HUB_FRAME_SCHEMA = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://tactical-media-broadcast-hub.dev/schemas/chat-hub-frame.json",
title: "ChatHubFrame",
oneOf: [ { required: ["message"] }, { required: ["action", "targetMessageId"] }, { required: ["messageId", "delivery"] }, { required: ["provider", "reason"] } ],
type: "object",
additionalProperties: false,
required: ["kind", "liveStreamId", "channelId", "seq"],
properties: {
kind: { const: "chat" },
liveStreamId: { type: "string", minLength: 1 },
channelId: { type: "string", minLength: 1 },
seq: { type: "integer", minimum: 1 },
message: CHAT_MESSAGE_SCHEMA,
action: { enum: ["delete", "timeout", "ban"] },
targetMessageId: { type: "string" },
targetHandle: { type: "string" },
messageId: { type: "string" },
delivery: { type: "array", items: DELIVERY_SCHEMA },
provider: { type: "string" },
reason: { type: "string" },
},
} as const;
export const DEVICE_PAIRING_SCHEMA = {
type: "object", additionalProperties: false, required: ["pairingCode", "qrPayload", "expiresAt"],
properties: { pairingCode: { type: "string", pattern: "^[A-Z0-9]{4}-[A-Z0-9]{4}$" }, qrPayload: { type: "string" }, expiresAt: { type: "string", format: "date-time" } },
} as const;
export const DEVICE_REDEEM_BODY_SCHEMA = {
type: "object", additionalProperties: false, required: ["pairingCode", "deviceName"],
properties: { pairingCode: { type: "string", pattern: "^[A-Z0-9]{4}-[A-Z0-9]{4}$" }, deviceName: { type: "string", minLength: 1, maxLength: 120 } },
} as const;
export const DEVICE_REDEEM_RESPONSE_SCHEMA = {
type: "object", additionalProperties: false, required: ["deviceToken", "workspaceId"],
properties: { deviceToken: { type: "string", minLength: 1 }, workspaceId: { type: "string", minLength: 1 } },
} as const;
export const SEND_CHAT_BODY_SCHEMA = {
type: "object", additionalProperties: false, required: ["text", "targets"],
properties: { text: { type: "string", minLength: 1, maxLength: 500 }, replyTo: { type: "string" }, targets: { oneOf: [{ const: "all" }, { type: "array", minItems: 1, maxItems: 32, items: { type: "string", minLength: 1 } }] } },
} as const;
export const MODERATE_CHAT_BODY_SCHEMA = {
type: "object", additionalProperties: false, required: ["action"],
properties: { action: { enum: ["delete", "timeout", "ban"] }, durationSeconds: { type: "integer", minimum: 1, maximum: 1209600 } },
if: { properties: { action: { const: "timeout" } } }, then: { required: ["durationSeconds"] },
} as const;
const wsObject = (op: string, required: readonly string[] = [], properties: Record<string, unknown> = {}) => ({
type: "object", additionalProperties: false, required: ["op", ...required], properties: { op: { const: op }, ...properties },
});
export const LIVE_CHAT_WS_SCHEMA = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://tactical-media-broadcast-hub.dev/schemas/live-chat-ws.json",
oneOf: [
wsObject("auth", ["token"], { token: { type: "string", minLength: 1 } }),
wsObject("ready"), wsObject("close", ["reason"], { reason: { type: "string" } }),
wsObject("subscribe", ["id", "topic", "liveId"], { id: { type: "string", minLength: 1 }, topic: { const: "livechat" }, liveId: { type: "string", minLength: 1 }, lastSeq: { type: "integer", minimum: 0 } }),
wsObject("subscribe", ["id", "topic", "channelId"], { id: { type: "string", minLength: 1 }, topic: { const: "channel" }, channelId: { type: "string", minLength: 1 }, lastSeq: { type: "integer", minimum: 0 } }),
wsObject("ack", ["id", "fromSeq"], { id: { type: "string" }, fromSeq: { type: "integer", minimum: 0 } }),
wsObject("nack", ["id", "reason"], { id: { type: "string" }, reason: { type: "string" } }),
wsObject("msg", ["topic", "seq", "type", "payload"], { topic: { enum: ["livechat", "channel"] }, seq: { type: "integer", minimum: 1 }, type: { enum: ["chat.message", "chat.moderated", "chat.delivery", "chat.gap", "stream.state", "capability"] }, payload: { type: "object" } }),
wsObject("resync", ["topic"], { topic: { enum: ["livechat", "channel"] } }),
wsObject("unsubscribe", ["id"], { id: { type: "string" } }), wsObject("ping"), wsObject("pong"),
],
} as const;
export const CHAT_CONTRACT_SCHEMAS = {
ws: LIVE_CHAT_WS_SCHEMA,
devicePairing: DEVICE_PAIRING_SCHEMA,
deviceRedeemBody: DEVICE_REDEEM_BODY_SCHEMA,
deviceRedeemResponse: DEVICE_REDEEM_RESPONSE_SCHEMA,
sendBody: SEND_CHAT_BODY_SCHEMA,
moderateBody: MODERATE_CHAT_BODY_SCHEMA,
chatMessage: CHAT_MESSAGE_SCHEMA,
delivery: DELIVERY_SCHEMA,
capability: LIVE_CHAT_CAPABILITY_SCHEMA,
chatHubFrame: CHAT_HUB_FRAME_SCHEMA,
} as const;
export type ChatContractSchemaName = keyof typeof CHAT_CONTRACT_SCHEMAS;
/** Reference fixtures typed by the TS contract (used by tests and clients). */
export const CHAT_CONTRACT_EXAMPLES: {
chatMessage: ChatMessage;
delivery: Delivery;
capability: LiveChatCapability;
} = {
chatMessage: {
seq: 1,
id: "00000000-0000-4000-8000-000000000001",
liveStreamId: "live-1",
connectionId: "00000000-0000-4000-8000-000000000002",
provider: "mock-livestream",
providerMessageId: "msg-1",
author: { handle: "viewer@example", color: "hsl(120 70% 62%)" },
text: "hello from the contract",
sentAt: "2026-10-07T10:00:00.000Z",
receivedAt: "2026-10-07T10:00:00.050Z",
isModerated: false,
source: "live-chat",
},
delivery: { connectionId: "00000000-0000-4000-8000-000000000002", provider: "mock-livestream", status: "sent" },
capability: {
connectionId: "00000000-0000-4000-8000-000000000002",
provider: "mock-livestream",
read: true,
reply: true,
moderate: true,
state: "ok",
},
};

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
CHAT_CONTRACT_EXAMPLES,
CHAT_CONTRACT_SCHEMAS,
CHAT_MESSAGE_SCHEMA,
CHAT_HUB_FRAME_SCHEMA,
} from "./chat-contract.schema.js";
import { authorColor, isValidChatMessage, type ChatMessage } from "./chat-contract.js";
/** Minimal draft-2020 subset validator — enough to prove schema/type parity
* without a dependency. Checks the constraints this contract relies on. */
function validate(schema: unknown, value: unknown, path = "$"): string[] {
const errors: string[] = [];
const s = schema as Record<string, unknown>;
if (Array.isArray(s.oneOf) && s.oneOf.filter(branch => validate(branch, value).length === 0).length !== 1) errors.push(`${path}: oneOf`);
if (s.if && validate(s.if, value).length === 0 && s.then) errors.push(...validate(s.then, value, path));
if (Array.isArray(value)) {
if (typeof s.minItems === "number" && value.length < s.minItems) errors.push(`${path}: minItems`);
if (typeof s.maxItems === "number" && value.length > s.maxItems) errors.push(`${path}: maxItems`);
}
if (s.const !== undefined && JSON.stringify(value) !== JSON.stringify(s.const)) errors.push(`${path}: const mismatch`);
if (s.enum !== undefined && !(s.enum as unknown[]).some(v => v === value)) errors.push(`${path}: not in enum`);
if (s.type !== undefined) {
const t = s.type as string;
const ok =
(t === "object" && typeof value === "object" && value !== null && !Array.isArray(value)) ||
(t === "array" && Array.isArray(value)) ||
(t === "string" && typeof value === "string") ||
(t === "boolean" && typeof value === "boolean") ||
(t === "integer" && typeof value === "number" && Number.isInteger(value));
if (!ok) { errors.push(`${path}: expected ${t}`); return errors; }
}
if (typeof value === "number") {
if (s.minimum !== undefined && value < (s.minimum as number)) errors.push(`${path}: below minimum`);
if (s.maximum !== undefined && value > (s.maximum as number)) errors.push(`${path}: above maximum`);
}
if (typeof value === "string") {
if (s.minLength !== undefined && value.length < (s.minLength as number)) errors.push(`${path}: too short`);
if (s.maxLength !== undefined && value.length > (s.maxLength as number)) errors.push(`${path}: too long`);
if (s.pattern !== undefined && !new RegExp(s.pattern as string).test(value)) errors.push(`${path}: pattern`);
}
if (Array.isArray(value) && s.items !== undefined) value.forEach((v, i) => errors.push(...validate(s.items, v, `${path}[${i}]`)));
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const props = (s.properties ?? {}) as Record<string, unknown>;
const record = value as Record<string, unknown>;
if (s.required !== undefined) for (const key of s.required as string[]) if (record[key] === undefined) errors.push(`${path}.${key}: required`);
if (s.additionalProperties === false) for (const key of Object.keys(record)) if (props[key] === undefined) errors.push(`${path}.${key}: additional property`);
for (const [key, sub] of Object.entries(props)) if (record[key] !== undefined) errors.push(...validate(sub, record[key], `${path}.${key}`));
}
return errors;
}
describe("S5 chat contract (frozen §3.5 shapes)", () => {
it("freezes pairing and op/liveId WS payloads, including zero resume cursor", () => {
expect(validate(CHAT_CONTRACT_SCHEMAS.ws, { op: "subscribe", id: "chat", topic: "livechat", liveId: "live-1", lastSeq: 0 })).toEqual([]);
expect(validate(CHAT_CONTRACT_SCHEMAS.ws, { op: "subscribe", id: "chat", topic: "livechat", liveStreamId: "live-1" })).not.toEqual([]);
expect(validate(CHAT_CONTRACT_SCHEMAS.devicePairing, { pairingCode: "ABCD-2345", qrPayload: "{}", expiresAt: "2026-10-08T00:00:00Z" })).toEqual([]);
expect(validate(CHAT_CONTRACT_SCHEMAS.sendBody, { text: "hi", targets: [] })).not.toEqual([]);
expect(validate(CHAT_CONTRACT_SCHEMAS.moderateBody, { action: "timeout" })).not.toEqual([]);
expect(validate(CHAT_CONTRACT_SCHEMAS.moderateBody, { action: "timeout", durationSeconds: 600 })).toEqual([]);
});
it("the schema examples satisfy the TypeScript types and the schema", () => {
const message: ChatMessage = CHAT_CONTRACT_EXAMPLES.chatMessage;
expect(isValidChatMessage(message)).toBe(true);
expect(validate(CHAT_CONTRACT_SCHEMAS.chatMessage, CHAT_CONTRACT_EXAMPLES.chatMessage)).toEqual([]);
expect(validate(CHAT_CONTRACT_SCHEMAS.delivery, CHAT_CONTRACT_EXAMPLES.delivery)).toEqual([]);
expect(validate(CHAT_CONTRACT_SCHEMAS.capability, CHAT_CONTRACT_EXAMPLES.capability)).toEqual([]);
expect(validate(CHAT_CONTRACT_SCHEMAS.chatHubFrame, {
kind: "chat", liveStreamId: "live-1", channelId: "ch-1", seq: 1, message: CHAT_CONTRACT_EXAMPLES.chatMessage,
})).toEqual([]);
});
it("the message schema rejects unknown fields, bad seq and bad source", () => {
const base = CHAT_CONTRACT_EXAMPLES.chatMessage;
expect(validate(CHAT_MESSAGE_SCHEMA, { ...base, extra: true })).toEqual([expect.stringContaining("extra")]);
expect(validate(CHAT_MESSAGE_SCHEMA, { ...base, seq: 0 })).toEqual([expect.stringContaining("seq")]);
expect(validate(CHAT_MESSAGE_SCHEMA, { ...base, source: "rumour" })).toEqual([expect.stringContaining("source")]);
expect(validate(CHAT_MESSAGE_SCHEMA, { ...base, author: { handle: "x" } })).toEqual([expect.stringContaining("color")]);
});
it("delivery only allows the four statuses; capability states include the paused reasons", () => {
expect(validate(CHAT_CONTRACT_SCHEMAS.delivery, { ...CHAT_CONTRACT_EXAMPLES.delivery, status: "flapped" })).not.toEqual([]);
for (const state of ["ok", "paused:quota", "paused:rate", "degraded"]) {
expect(validate(CHAT_CONTRACT_SCHEMAS.capability, { ...CHAT_CONTRACT_EXAMPLES.capability, state })).toEqual([]);
}
});
it("a hub frame carries kind chat and a channel-scoped seq", () => {
const frame = { kind: "chat", liveStreamId: "live-1", channelId: "ch-1", seq: 3, message: CHAT_CONTRACT_EXAMPLES.chatMessage };
expect(validate(CHAT_HUB_FRAME_SCHEMA, frame)).toEqual([]);
expect(validate(CHAT_HUB_FRAME_SCHEMA, { ...frame, kind: "state" })).toEqual([expect.stringContaining("kind")]);
});
it("author colour is a stable function of provider:handle, never identity-fused", () => {
expect(authorColor("twitch", "viewer")).toBe(authorColor("twitch", "viewer"));
expect(authorColor("twitch", "viewer")).not.toBe(authorColor("youtube", "viewer"));
expect(authorColor("twitch", "viewer")).toMatch(/^hsl\(\d+ 70% 62%\)$/);
expect(authorColor("twitch", "viewer")).toMatch(/^hsl\([0-5]\d |hsl\([1-9]\d\d /);
});
it("isValidChatMessage rejects a non-integer seq and a missing author", () => {
const base = CHAT_CONTRACT_EXAMPLES.chatMessage;
expect(isValidChatMessage({ ...base, seq: 1.5 })).toBe(false);
expect(isValidChatMessage({ ...base, author: { handle: "" , color: "hsl(1 70% 62%)" } })).toBe(false);
expect(isValidChatMessage({ ...base, author: undefined })).toBe(false);
});
});

View file

@ -0,0 +1,302 @@
/**
* S5 chat-unification contract (streaming-architecture.md §3.5).
*
* This module is the FROZEN wire contract between the Tactical Media Broadcast Hub chat hub,
* the web Control Room and the Opticast client. Both ends implement these
* shapes; changing them is a version bump, not a drive-by edit. Handwritten
* JSON Schema lives in `chat-contract.schema.ts` (validated against the types
* in `chat-contract.test.ts`) so clients can validate a transcript without a
* code-generation step and without a new dependency.
*/
/** Topic names multiplexed on the single /realtime socket. */
export type LiveChatTopic = "livechat" | "channel";
/** WS frame operations (§3.5 wire). Nest control ops reuse the gateway's. */
export type LiveChatFrameOp =
| "auth"
| "ready"
| "close"
| "subscribe"
| "ack"
| "nack"
| "msg"
| "resync"
| "unsubscribe"
| "ping"
| "pong";
/** `msg.type` values on the livechat topic. */
export type LiveChatEventType =
| "chat.message"
| "chat.moderated"
| "chat.delivery"
| "chat.gap"
| "stream.state"
| "capability";
/**
* Platforms the hub normalizes today (§3.2 table). These are open string
* unions, not a provider registry: the hub resolves adapters by connector id
* and the wire stays open so a new platform needs no contract change.
*/
export type LiveChatProvider = (string & {});
/** Message provenance. `system` covers gap markers and hub notices. */
export type LiveChatSource = "live-chat" | "social-reply" | "system";
export interface LiveChatAuthor {
/** Provider login/handle, never empty. */
handle: string;
displayName?: string;
avatarUrl?: string;
badges?: string[];
/** Hash of `provider:handle` (§3.5 identity rule); a CSS colour. */
color: string;
}
/** Normalized chat message on the wire. Dates are ISO strings. */
export interface ChatMessage {
/** Hub-assigned per-live monotonic ordering. Authoritative for cursors. */
seq: number;
id: string;
liveStreamId: string;
connectionId: string;
provider: string;
providerMessageId: string;
author: LiveChatAuthor;
text: string;
sentAt: string;
receivedAt: string;
isModerated: boolean;
replyTo?: string;
source: LiveChatSource;
}
/** Per-target send outcome; one row per (message, target). */
export interface Delivery {
connectionId: string;
provider: string;
status: "queued" | "sent" | "failed" | "unsupported";
error?: string;
}
export interface LiveChatCapability {
connectionId: string;
provider: string;
read: boolean;
reply: boolean;
moderate: boolean;
state: "ok" | "paused:quota" | "paused:rate" | "degraded";
reason?: string;
}
export interface LiveSummary {
liveStreamId: string;
connectionId: string;
provider: string;
title: string;
state: "created" | "waiting" | "live" | "ended";
}
/** Chat message envelope pushed on the hub topic (WS `msg` payload). */
export interface ChatMessageFrame {
kind: "chat";
liveStreamId: string;
channelId: string;
/** Same value as the message's `seq`; resume cursors use it. */
seq: number;
message: ChatMessage;
}
export interface ChatModeratedFrame {
kind: "chat";
liveStreamId: string;
channelId: string;
seq: number;
action: "delete" | "timeout" | "ban";
targetMessageId: string;
targetHandle?: string;
}
export interface ChatDeliveryFrame {
kind: "chat";
liveStreamId: string;
channelId: string;
seq: number;
messageId: string;
delivery: Delivery[];
}
/** Emitted when a provider gap is accepted (e.g. a Twitch WS drop). */
export interface ChatGapFrame {
kind: "chat";
liveStreamId: string;
channelId: string;
seq: number;
provider: string;
reason: string;
}
export type ChatHubFrame = ChatMessageFrame | ChatModeratedFrame | ChatDeliveryFrame | ChatGapFrame;
// ---------------------------------------------------------------------------
// WS frames (both ends implement; §3.5 wire table)
// ---------------------------------------------------------------------------
export interface WsAuthFrame {
op: "auth";
/** Bearer token (session or device). Never in the URL. */
token: string;
}
export type WsSubscribeFrame = {
op: "subscribe";
id: string;
/** Resume cursor; absent means latest only. Zero requests all retained frames. */
lastSeq?: number;
} & ({ topic: "livechat"; liveId: string } | { topic: "channel"; channelId: string });
export interface WsAckFrame {
op: "ack";
id: string;
fromSeq: number;
}
export interface WsNackFrame {
op: "nack";
id: string;
reason: string;
}
export interface WsMsgFrame<T = ChatHubFrame> {
op: "msg";
topic: LiveChatTopic;
seq: number;
type: LiveChatEventType;
payload: T;
}
export interface WsResyncFrame {
op: "resync";
topic: LiveChatTopic;
}
export interface WsUnsubscribeFrame {
op: "unsubscribe";
id: string;
}
export interface WsPingFrame {
op: "ping";
}
export interface WsPongFrame {
op: "pong";
}
export interface WsReadyFrame { op: "ready" }
export interface WsCloseFrame { op: "close"; reason: string }
export type LiveChatWsFrame =
| WsReadyFrame
| WsCloseFrame
| WsAuthFrame
| WsSubscribeFrame
| WsAckFrame
| WsNackFrame
| WsMsgFrame
| WsResyncFrame
| WsUnsubscribeFrame
| WsPingFrame
| WsPongFrame;
// ---------------------------------------------------------------------------
// REST request bodies (§3.5 endpoints; workspace from session/device token)
// ---------------------------------------------------------------------------
export interface SendChatMessageBody {
/** 1..500 characters. */
text: string;
targets: "all" | string[];
replyTo?: string;
}
export interface ModerateChatBody {
action: "delete" | "timeout" | "ban";
/** 1..1209600, required for timeout. */
durationSeconds?: number;
}
/** Issuance has an empty body; workspace and user come from the session. */
export type DevicePairingRequest = Record<string, never>;
export interface DevicePairingResponse {
pairingCode: string;
qrPayload: string;
expiresAt: string;
}
export interface DeviceRedeemBody {
pairingCode: string;
deviceName: string;
}
export interface DeviceRedeemResponse {
deviceToken: string;
workspaceId: string;
}
/** Device token claims, mirrored from the `devices` row. */
export interface DeviceTokenPayload {
deviceId: string;
workspaceId: string;
userId: string;
scopes: string[];
epoch: number;
issuedAt: number;
expiresAt: number;
}
export const DEVICE_SCOPES = ["livechat.view", "livechat.send", "livechat.moderate", "stream.view"] as const;
export type DeviceScope = (typeof DEVICE_SCOPES)[number];
/** Deterministic author colour: hash of `provider:handle` (§3.5 identity). */
export function authorColor(provider: string, handle: string): string {
let hash = 0x811c9dc5;
const key = `${provider}:${handle}`;
for (let i = 0; i < key.length; i += 1) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
const hue = ((hash >>> 0) % 360 + 360) % 360;
return `hsl(${hue} 70% 62%)`;
}
/** Validate a transcript payload against the frozen message shape. */
export function isValidChatMessage(value: unknown): value is ChatMessage {
if (typeof value !== "object" || value === null) return false;
const m = value as Record<string, unknown>;
return (
typeof m.seq === "number" &&
Number.isSafeInteger(m.seq) &&
m.seq > 0 &&
typeof m.id === "string" &&
typeof m.liveStreamId === "string" &&
typeof m.connectionId === "string" &&
typeof m.provider === "string" &&
typeof m.providerMessageId === "string" &&
typeof m.text === "string" &&
typeof m.sentAt === "string" &&
typeof m.receivedAt === "string" &&
typeof m.isModerated === "boolean" &&
typeof m.source === "string" &&
(m.source === "live-chat" || m.source === "social-reply" || m.source === "system") &&
typeof m.author === "object" &&
m.author !== null &&
typeof (m.author as Record<string, unknown>).handle === "string" &&
String((m.author as Record<string, unknown>).handle).length > 0 &&
typeof (m.author as Record<string, unknown>).color === "string" &&
(m.replyTo === undefined || typeof m.replyTo === "string")
);
}

View file

@ -0,0 +1,51 @@
import type { ConnectorCapabilities } from "./capabilities.js";
/**
* Credential references are opaque handles into the vault (WP2).
* Raw tokens never appear in these types by design.
*/
export interface CredentialRef {
readonly kind: "credential-ref";
readonly id: string;
}
export type ProviderId = string;
export interface Channel {
id: string;
workspaceId: string;
provider: ProviderId;
displayName: string;
createdAt: Date;
}
export interface ConnectorConnection {
id: string;
workspaceId: string;
provider: ProviderId;
channelId: string;
credentialId: CredentialRef;
/** Manifest-declared field values, e.g. instanceUrl for federated platforms. */
fields: Record<string, string>;
createdAt: Date;
}
export interface NormalizedIdentity {
provider: ProviderId;
remoteId: string;
handle: string;
displayName?: string;
avatarUrl?: string;
}
/** Channel on a remote video platform; account ≠ channel (federated platforms). */
export interface RemoteVideoChannel {
id: string;
provider: ProviderId;
instanceUrl: string;
remoteChannelId: string;
handle: string;
displayName?: string;
avatarUrl?: string;
capabilities: ConnectorCapabilities;
}

View file

@ -0,0 +1,27 @@
/**
* Normalized domain types shared by every workspace app. Connector packages
* never leak provider names into this package — enforced by the root lint rule.
*/
export interface HealthState {
status: "up" | "down";
}
export * from "./capabilities.js";
export * from "./chat-contract.js";
export * from "./chat-contract.schema.js";
export * from "./connection.js";
export * from "./messaging.js";
export * from "./publishing.js";
export * from "./shared-files.js";
export * from "./social.js";
export * from "./stream-columns.js";
export * from "./vc.js";
export * from "./video.js";
export * from "./workspace.js";
// publishing.ts retains the legacy VideoAsset declaration for connector
// compatibility. Prefer the first-class video-domain declaration here.
export type { VideoAsset } from "./video.js";
export * from "./publishing-execution.js";

View file

@ -0,0 +1,47 @@
/** Transport-agnostic list page — shared by every paginated surface. */
export interface Page<T, C = string> {
items: T[];
nextCursor?: C;
}
export interface RemoteMedia {
remoteId: string;
url?: string;
mimeType: string;
width?: number;
height?: number;
}
/** Result of resolving a live source for program input (WP6 go-live slice). */
export interface LiveSourceResolution {
/** Absolute HLS playback URL, or null when the source is not live. */
playbackHlsUrl: string | null;
live: boolean;
}
export interface StreamPlaybackSource {
remoteId: string;
/** Absolute playback URL (HLS manifest, embed page, etc.). */
url: string;
kind: "hls" | "dash" | "embed";
protocol: "https" | "rtmp" | "rtmps" | "srt";
}
export interface DirectMessage {
id: string;
provider: string;
conversationRemoteId: string;
fromHandle: string;
toHandle: string;
text: string;
sentAt: Date;
}
export interface NormalizedWebhookEvent {
provider: string;
eventType: string;
/** Connection or channel the event belongs to, when resolvable. */
connectionKey?: string;
payload: Record<string, unknown>;
receivedAt: Date;
}

View file

@ -0,0 +1,7 @@
export function publishingIdempotencyKey(parts: { workspaceId: string; draftId: string; attemptNo: string | bigint | number }): string {
if (!/^[A-Za-z0-9-]+$/.test(parts.workspaceId) || !/^[A-Za-z0-9-]+$/.test(parts.draftId)) throw new RangeError("invalid attempt identity");
if (typeof parts.attemptNo === "number" && !Number.isSafeInteger(parts.attemptNo)) throw new RangeError("unsafe attempt number");
const attempt = String(parts.attemptNo);
if (!/^[1-9][0-9]*$/.test(attempt)) throw new RangeError("attemptNo must be a positive decimal integer");
return `wp3:${parts.workspaceId}:${parts.draftId}:a${attempt}:publish`;
}

View file

@ -0,0 +1,109 @@
export type Visibility =
| "public"
| "unlisted"
| "private"
| "followers"
| "provider_default";
export type FormatIntent =
| "short_form"
| "long_form"
| "story"
| "feed_video"
| "provider_default";
export interface MediaAsset {
id: string;
workspaceId: string;
storageKey: string;
mimeType: string;
sizeBytes: number;
width?: number;
height?: number;
createdAt: Date;
}
export interface NormalizedPost {
text: string;
mediaAssetIds: string[];
visibility: Visibility;
scheduledAt?: Date;
providerMetadata: Record<string, unknown>;
}
export interface Draft {
id: string;
workspaceId: string;
authorId: string;
post: NormalizedPost;
targets: PostTarget[];
state: "editing" | "in_review" | "approved" | "rejected" | "scheduled" | "published";
reviewerId?: string;
createdAt: Date;
updatedAt: Date;
}
export interface PostTarget {
channelId: string;
connectionId: string;
}
export interface Publication {
id: string;
workspaceId: string;
draftId: string;
connectionId: string;
remoteId: string;
remoteUrl?: string;
publishedAt: Date;
providerMetadata: Record<string, unknown>;
}
export interface ScheduledPublication {
id: string;
workspaceId: string;
draftId: string;
publishAt: Date;
targets: PostTarget[];
idempotencyKey: string;
state: "pending" | "in_flight" | "done" | "failed";
}
export interface VideoAsset {
id: string;
workspaceId: string;
storageKey: string;
mimeType: string;
sizeBytes: number;
width?: number;
height?: number;
durationMs?: number;
frameRate?: number;
codec?: string;
audioCodec?: string;
}
export interface NormalizedVideoPublication {
videoAssetId: string;
title?: string;
description?: string;
tags?: string[];
thumbnailAssetId?: string;
visibility: Visibility;
formatIntent: FormatIntent;
scheduledAt?: Date;
providerMetadata: Record<string, unknown>;
}
export type VideoProcessingState =
| "uploading"
| "processing"
| "ready"
| "failed";
export interface VideoProcessingStatus {
remoteId: string;
state: VideoProcessingState;
progressPercent?: number;
error?: string;
}

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { attachmentArchiveKey, canArchiveAttachment, type GroupFileMapping, type MatrixAttachmentCandidate } from "./shared-files.js";
const mapping: GroupFileMapping = {
workspaceId: "workspace", groupId: "group", roomId: "!archive:example.org",
spaceId: "space", folderId: "folder", archivePolicy: "unencrypted-archive",
retention: { mode: "keep", legalHold: false },
};
const event: MatrixAttachmentCandidate = {
roomId: mapping.roomId, eventId: "$event", senderId: "@sender:example.org",
eventType: "m.room.message", msgtype: "m.file", roomEncryption: "unencrypted",
encryptedFile: false, redacted: false, mxcUri: "mxc://example.org/media",
};
describe("group file archive policy", () => {
it("accepts only attachments in an explicitly mapped unencrypted archive room", () => {
for (const msgtype of ["m.file", "m.image", "m.audio", "m.video"]) {
expect(canArchiveAttachment(mapping, { ...event, msgtype })).toBe(true);
}
expect(canArchiveAttachment({ ...mapping, archivePolicy: "disabled" }, event)).toBe(false);
expect(canArchiveAttachment(mapping, { ...event, roomId: "!other:example.org" })).toBe(false);
});
it("fails closed on encryption, unknown state, redactions and non-attachment messages", () => {
for (const patch of [
{ roomEncryption: "encrypted" as const }, { roomEncryption: "unknown" as const },
{ encryptedFile: true }, { redacted: true }, { eventType: "m.room.encrypted" },
{ msgtype: "m.text" }, { mxcUri: "https://attacker.example/file" }, { mxcUri: "mxc://example.org/" },
]) expect(canArchiveAttachment(mapping, { ...event, ...patch })).toBe(false);
});
it("replays use the same identity without conflating tenants or rooms", () => {
expect(attachmentArchiveKey("w", "r", "e")).toBe(attachmentArchiveKey("w", "r", "e"));
expect(attachmentArchiveKey("w2", "r", "e")).not.toBe(attachmentArchiveKey("w", "r", "e"));
expect(attachmentArchiveKey("a:b", "c", "e")).not.toBe(attachmentArchiveKey("a", "b:c", "e"));
});
});

View file

@ -0,0 +1,109 @@
/** Shared file/index contracts. Payloads are remote; these records contain metadata only. */
/** A share expiry never deletes a file. UTC ISO-8601 for timed retention. */
export type FileRetention = { legalHold: boolean } & (
| { mode: "keep"; expiresAt?: never }
| { mode: "until"; expiresAt: string }
);
export interface GroupFileMapping {
workspaceId: string;
groupId: string;
/** Immutable Matrix room ID, never an alias or display name. */
roomId: string;
spaceId: string;
folderId: string;
archivePolicy: "disabled" | "unencrypted-archive";
retention: FileRetention;
}
export type SharedFileOrigin =
| { kind: "matrix"; mxcUri: string }
| { kind: "bridge"; mxcUri: string; providerId: string; remoteEventId?: string }
| { kind: "upload" }
| { kind: "pad-export"; padId: string; revision: string };
export interface GroupFile {
id: string;
workspaceId: string;
groupId: string;
roomId: string;
senderId: string;
/** Null for a direct upload/export until a link is posted in the room. */
eventId: string | null;
origin: SharedFileOrigin;
name: string;
mimeType: string;
sizeBytes: number;
/** SHA-256 of verified payload bytes, lowercase hex; never an S3 ETag. */
checksum: { algorithm: "sha256"; value: string };
createdAt: string;
indexedAt: string;
file: { spaceId: string; resourceId: string; version: string };
/** Authenticated library link by default; never a persisted presigned R2 URL. */
shareLink: { url: string; access: "members" | "public"; expiresAt: string | null };
retention: FileRetention;
status: "available" | "revoked" | "expired";
}
export interface MatrixAttachmentCandidate {
roomId: string;
eventId: string;
senderId: string;
eventType: string;
msgtype: string;
/** Unknown state must fail closed, including on initial sync. */
roomEncryption: "unencrypted" | "encrypted" | "unknown";
encryptedFile: boolean;
redacted: boolean;
mxcUri: string;
}
/** Eligibility only; the adapter must also check membership/ACL, quotas and actual bytes. */
export function canArchiveAttachment(
mapping: GroupFileMapping,
event: MatrixAttachmentCandidate,
): boolean {
return mapping.archivePolicy === "unencrypted-archive"
&& mapping.roomId === event.roomId
&& event.roomEncryption === "unencrypted"
&& !event.encryptedFile
&& !event.redacted
&& event.eventType === "m.room.message"
&& ["m.file", "m.image", "m.audio", "m.video"].includes(event.msgtype)
&& /^mxc:\/\/[^/\s]+\/[^/\s?#]+$/.test(event.mxcUri);
}
/** Collision-free replay identity; DB uniqueness is scoped to workspace/room/event. */
export function attachmentArchiveKey(workspaceId: string, roomId: string, eventId: string): string {
return JSON.stringify([workspaceId, roomId, eventId]);
}
/** Future adapter boundary, not a running bot or public API. Commit before advancing sync. */
export interface HubArchivePort {
archive(mapping: GroupFileMapping, event: MatrixAttachmentCandidate): Promise<GroupFile>;
revoke(workspaceId: string, roomId: string, eventId: string): Promise<void>;
}
export interface CollaborativeDocument {
fileId: string;
mimeType: "application/vnd.oasis.opendocument.text";
editor: "wopi";
/** Authenticated launch URL; WOPI access tokens are short-lived and not indexed. */
launchUrl: string;
}
/** Etherpad/HedgeDoc-compatible link/export seam. No pad text or revisions in Hub DB. */
export interface SharedPad {
id: string;
workspaceId: string;
groupId: string;
roomId: string;
service: "etherpad-compatible" | "hedgedoc-compatible";
url: string;
payloadStore: "r2";
retention: FileRetention;
}
export interface PadExportPort {
exportRevision(pad: SharedPad, revision: string): Promise<GroupFile>;
}

View file

@ -0,0 +1,89 @@
import type { ProviderId } from "./connection.js";
export interface FeedItem {
id: string;
provider: ProviderId;
remoteId: string;
authorHandle: string;
text: string;
mediaUrls: string[];
createdAt: Date;
metrics: Record<string, number>;
}
export interface Feed {
id: string;
workspaceId: string;
connectionId: string;
kind: "home" | "mentions" | "search" | "list";
query?: string;
}
export interface Conversation {
id: string;
workspaceId: string;
connectionId: string;
participantHandles: string[];
lastMessageAt: Date;
unreadCount: number;
assignedUserId?: string;
}
export interface Comment {
id: string;
provider: ProviderId;
remoteId: string;
onRemoteId: string;
authorHandle: string;
text: string;
createdAt: Date;
}
export interface Reaction {
id: string;
provider: ProviderId;
onRemoteId: string;
kind: string;
count: number;
}
export interface Metric {
id: string;
workspaceId: string;
subjectType: "publication" | "channel" | "livestream";
subjectId: string;
capturedAt: Date;
values: Record<string, number>;
}
export interface AnalyticsSnapshot {
subjectId: string;
windowStart: Date;
windowEnd: Date;
series: readonly Metric[];
}
export interface AuditLogEntry {
id: string;
workspaceId: string;
actorUserId: string;
actorRole: string;
action: string;
targetType: string;
targetId: string;
diff: Record<string, unknown> | null;
ip?: string;
sessionId?: string;
at: Date;
prevHash?: string;
hash: string;
}
export interface WebhookEvent {
id: string;
workspaceId: string;
provider: ProviderId;
eventType: string;
payload: Record<string, unknown>;
receivedAt: Date;
}

View file

@ -0,0 +1,225 @@
import { describe, expect, it } from "vitest";
import {
ACCOUNTS_MAX,
clampIntervalMs,
clampPosition,
clampTitle,
DEFAULT_INTERVAL_MS,
defaultIntervalMs,
isKeywordLike,
parseRefreshPolicyClamped,
parseStreamColumnSpec,
QUERY_MAX,
STREAM_COLUMN_TITLE_MAX,
StreamColumnsValidationError,
} from "./stream-columns.js";
const rejects = (fn: () => unknown): void => {
expect(fn).toThrow(StreamColumnsValidationError);
};
describe("column type dispatch", () => {
it("covers exactly the four persisted types", () => {
expect(
parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: ["home"] }),
).toEqual({ connectionId: "c1", kinds: ["home"] });
expect(
parseStreamColumnSpec("saved_search", { query: "product", platforms: ["bluesky"] }),
).toEqual({ query: "product", platforms: ["bluesky"], matchMode: "any" });
expect(
parseStreamColumnSpec("keyword", { query: "acampada26", platforms: ["bluesky", "mastodon"] }),
).toEqual({ query: "acampada26", platforms: ["bluesky", "mastodon"], matchMode: "any" });
expect(
parseStreamColumnSpec("account_list", {
accounts: [{ provider: "mastodon", handle: "@user@mastodon.social" }],
}),
).toEqual({ accounts: [{ provider: "mastodon", handle: "@user@mastodon.social" }] });
});
it("treats saved_search as keyword-shaped (shared engine, §1.1)", () => {
const good = { query: "t", platforms: ["bluesky"] };
expect(parseStreamColumnSpec("saved_search", good)).toEqual(
parseStreamColumnSpec("keyword", good),
);
rejects(() => parseStreamColumnSpec("saved_search", { platforms: ["bluesky"] }));
});
it("isKeywordLike groups the shared keyword engine", () => {
expect(isKeywordLike("keyword")).toBe(true);
expect(isKeywordLike("saved_search")).toBe(true);
expect(isKeywordLike("platform_feed")).toBe(false);
expect(isKeywordLike("account_list")).toBe(false);
});
});
describe("platform_feed spec", () => {
it("defaults kinds to [\"home\"] when omitted or empty is rejected", () => {
expect(parseStreamColumnSpec("platform_feed", { connectionId: "c1" }))
.toEqual({ connectionId: "c1", kinds: ["home"] });
rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: [] }));
});
it("accepts home+mentions in any order, deduped", () => {
expect(parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: ["mentions", "home", "mentions"] }))
.toEqual({ connectionId: "c1", kinds: ["mentions", "home"] });
});
it("rejects bad kinds and connection ids", () => {
rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: ["home", "list"] }));
rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: "c1", kinds: "home" }));
rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: "", kinds: ["home"] }));
rejects(() => parseStreamColumnSpec("platform_feed", { connectionId: 42, kinds: ["home"] }));
rejects(() => parseStreamColumnSpec("platform_feed", {}));
rejects(() => parseStreamColumnSpec("platform_feed", null));
rejects(() => parseStreamColumnSpec("platform_feed", "c1"));
rejects(() => parseStreamColumnSpec("platform_feed", []));
});
});
describe("keyword / saved_search spec", () => {
it("trims the query and rejects empty-after-trim", () => {
expect(parseStreamColumnSpec("keyword", { query: " acampada26 ", platforms: ["bluesky"] }).query)
.toBe("acampada26");
rejects(() => parseStreamColumnSpec("keyword", { query: " ", platforms: ["bluesky"] }));
rejects(() => parseStreamColumnSpec("keyword", { query: "", platforms: ["bluesky"] }));
rejects(() => parseStreamColumnSpec("keyword", { platforms: ["bluesky"] }));
rejects(() => parseStreamColumnSpec("keyword", { query: 7, platforms: ["bluesky"] }));
});
it("enforces the 1..200 query window and control-char ban", () => {
const edge = "x".repeat(QUERY_MAX);
expect(parseStreamColumnSpec("keyword", { query: ` ${edge} `, platforms: ["bluesky"] }).query)
.toBe(edge); // exactly 200 after trim passes
rejects(() => parseStreamColumnSpec("keyword", { query: `${edge}x`, platforms: ["bluesky"] }));
rejects(() => parseStreamColumnSpec("keyword", { query: "a\u0000b", platforms: ["bluesky"] }));
rejects(() => parseStreamColumnSpec("keyword", { query: "a\nb", platforms: ["bluesky"] }));
rejects(() => parseStreamColumnSpec("keyword", { query: "a\u007fb", platforms: ["bluesky"] }));
});
it("dedupes platforms and rejects empty/invalid lists", () => {
expect(parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky", "bluesky", "youtube"] }).platforms)
.toEqual(["bluesky", "youtube"]);
rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: [] }));
rejects(() => parseStreamColumnSpec("keyword", { query: "q" }));
rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky", ""] }));
rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: [42] }));
});
it("allows only the reserved matchMode \"any\"", () => {
expect(parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky"], matchMode: "any" }).matchMode)
.toBe("any");
rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky"], matchMode: "all" }));
rejects(() => parseStreamColumnSpec("keyword", { query: "q", platforms: ["bluesky"], matchMode: null }));
});
});
describe("account_list spec", () => {
it("trims handles and accepts the documented shapes", () => {
expect(
parseStreamColumnSpec("account_list", {
accounts: [
{ provider: "mastodon", handle: " @user@mastodon.social " },
{ provider: "bluesky", handle: "@user.bsky.social" },
{ provider: "youtube", handle: "UCxxxxxxxxxxxxxxxxxxxxxx" },
],
}).accounts,
).toEqual([
{ provider: "mastodon", handle: "@user@mastodon.social" },
{ provider: "bluesky", handle: "@user.bsky.social" },
{ provider: "youtube", handle: "UCxxxxxxxxxxxxxxxxxxxxxx" },
]);
});
it("enforces the 1..50 window", () => {
const account = { provider: "mastodon", handle: "@user" };
expect(parseStreamColumnSpec("account_list", { accounts: [account] }).accounts).toHaveLength(1);
expect(
parseStreamColumnSpec("account_list", { accounts: Array.from({ length: ACCOUNTS_MAX }, () => account) }).accounts,
).toHaveLength(ACCOUNTS_MAX);
rejects(() => parseStreamColumnSpec("account_list", { accounts: [] }));
rejects(() =>
parseStreamColumnSpec("account_list", {
accounts: Array.from({ length: ACCOUNTS_MAX + 1 }, () => account),
}),
);
});
it("rejects malformed entries", () => {
rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ provider: "mastodon" }] }));
rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ handle: "@user" }] }));
rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ provider: "", handle: "@u" }] }));
rejects(() => parseStreamColumnSpec("account_list", { accounts: [{ provider: "mastodon", handle: " " }] }));
rejects(() =>
parseStreamColumnSpec("account_list", { accounts: [{ provider: "mastodon", handle: "a\u0000b" }] }),
);
rejects(() => parseStreamColumnSpec("account_list", { accounts: ["@user"] }));
rejects(() => parseStreamColumnSpec("account_list", { accounts: {} }));
});
});
describe("refresh_policy", () => {
it("defaults to {}", () => {
expect(parseRefreshPolicyClamped()).toEqual({});
expect(parseRefreshPolicyClamped({})).toEqual({});
expect(parseRefreshPolicyClamped(undefined)).toEqual({});
expect(parseRefreshPolicyClamped(null)).toEqual({});
});
it("keeps valid interval/paused values", () => {
expect(parseRefreshPolicyClamped({ intervalMs: 300_000, paused: true }))
.toEqual({ intervalMs: 300_000, paused: true });
expect(parseRefreshPolicyClamped({ paused: false })).toEqual({ paused: false });
});
it("clamps intervalMs into 60_000..3_600_000", () => {
expect(parseRefreshPolicyClamped({ intervalMs: 0 }).intervalMs).toBe(60_000);
expect(parseRefreshPolicyClamped({ intervalMs: -5_000 }).intervalMs).toBe(60_000);
expect(parseRefreshPolicyClamped({ intervalMs: 59_999 }).intervalMs).toBe(60_000);
expect(parseRefreshPolicyClamped({ intervalMs: 10 }).intervalMs).toBe(60_000);
expect(parseRefreshPolicyClamped({ intervalMs: 3_600_000 }).intervalMs).toBe(3_600_000);
expect(parseRefreshPolicyClamped({ intervalMs: 10_000_000 }).intervalMs).toBe(3_600_000);
expect(parseRefreshPolicyClamped({ intervalMs: 90_500 }).intervalMs).toBe(90_500);
expect(parseRefreshPolicyClamped({ intervalMs: 60_000.4 }).intervalMs).toBe(60_000);
expect(parseRefreshPolicyClamped({ intervalMs: Number.NaN }).intervalMs).toBe(60_000);
expect(parseRefreshPolicyClamped({ intervalMs: Number.POSITIVE_INFINITY }).intervalMs).toBe(3_600_000);
expect(parseRefreshPolicyClamped({ intervalMs: Number.NEGATIVE_INFINITY }).intervalMs).toBe(60_000);
});
it("rejects malformed policies", () => {
rejects(() => parseRefreshPolicyClamped({ intervalMs: "60000" }));
rejects(() => parseRefreshPolicyClamped({ paused: "yes" }));
rejects(() => parseRefreshPolicyClamped([1, 2]));
rejects(() => parseRefreshPolicyClamped("paused"));
});
it("exposes the per-type default cadences", () => {
expect(DEFAULT_INTERVAL_MS).toEqual({
platform_feed: 60_000,
keyword: 120_000,
account_list: 180_000,
saved_search: 300_000,
});
for (const type of ["platform_feed", "keyword", "account_list", "saved_search"] as const) {
expect(defaultIntervalMs(type)).toBe(DEFAULT_INTERVAL_MS[type]);
expect(defaultIntervalMs(type)).toBeGreaterThanOrEqual(clampIntervalMs(60_000));
}
});
});
describe("shared clamps", () => {
it("clampTitle trims and caps at 80 chars (mirrors the DB CHECK)", () => {
expect(clampTitle(" Movement voices ")).toBe("Movement voices");
expect(clampTitle("x".repeat(STREAM_COLUMN_TITLE_MAX))).toHaveLength(STREAM_COLUMN_TITLE_MAX);
expect(clampTitle(` ${"y".repeat(STREAM_COLUMN_TITLE_MAX + 20)} `)).toHaveLength(STREAM_COLUMN_TITLE_MAX);
expect(clampTitle("").length).toBe(0);
});
it("clampPosition yields finite non-negative integers", () => {
expect(clampPosition(0)).toBe(0);
expect(clampPosition(7.9)).toBe(8);
expect(clampPosition(-3)).toBe(0);
expect(clampPosition(Number.NaN)).toBe(0);
expect(clampPosition(Number.POSITIVE_INFINITY)).toBe(2_147_483_647);
expect(clampPosition(2_147_483_648)).toBe(2_147_483_647);
});
});

View file

@ -0,0 +1,304 @@
/**
* Streams columns (WP3 §1.2): typed `spec` per column type + clamped
* `refresh_policy`. Invalid specs are rejected before insert (the API maps
* StreamColumnsValidationError → 422) and never stored; clamps are applied,
* not errors — intervalMs floors to 60 s / caps at 1 h, titles trim+cap at 80
* chars, positions stay sane dense ranks.
*
* Dependency-free by house rule (see chat-contract.schema.ts): plain parse
* functions, no zod. The four persisted types and their shapes follow design
* §1.1/§1.2; `saved_search` shares the keyword engine, it only differs in the
* column `type` label.
*/
import type { ProviderId } from "./connection.js";
export type StreamColumnType = "platform_feed" | "keyword" | "account_list" | "saved_search";
export const STREAM_COLUMN_TYPES: readonly StreamColumnType[] = [
"platform_feed",
"keyword",
"account_list",
"saved_search",
] as const;
export type PlatformFeedKind = "home" | "mentions";
/** `{ connectionId, kinds }` — kinds defaults to ["home"] (design §1.2). */
export interface PlatformFeedSpec {
connectionId: string;
kinds: PlatformFeedKind[];
}
/** Shared by `keyword` and `saved_search` (§1.1: saved_search is keyword). */
export interface KeywordSpec {
/** 1..200 chars after trimming; no NUL/control chars. */
query: string;
/** Provider ids, e.g. ["bluesky","mastodon","youtube"]; deduped. */
platforms: ProviderId[];
/** Reserved; v1 = single term set, no boolean ops. */
matchMode: "any";
}
export interface AccountListEntry {
provider: ProviderId;
/** Free text: "@user@instance.social" | "@user" | "UC…" (YT channel id). */
handle: string;
}
/** 1..50 entries (design §1.2). */
export interface AccountListSpec {
accounts: AccountListEntry[];
}
export type StreamColumnSpec = PlatformFeedSpec | KeywordSpec | AccountListSpec;
/** `{ intervalMs?, paused? }` — clamped server-side (design §1.2). */
export interface RefreshPolicy {
/** 60_000..3_600_000 after clamping. */
intervalMs?: number;
paused?: boolean;
}
export const QUERY_MAX = 200;
export const ACCOUNTS_MAX = 50;
export const PLATFORMS_MAX = 200;
/** Title cap: matches the stream_columns.title CHECK in migration 021. */
export const STREAM_COLUMN_TITLE_MAX = 80;
export const STREAM_COLUMN_POSITION_DEFAULT = 0;
const POSITION_MAX = 2_147_483_647; // integer column
const INTERVAL_FLOOR = 60_000;
const INTERVAL_CEIL = 3_600_000;
/** Default cadence per column type (design §1.2). */
export const DEFAULT_INTERVAL_MS: Record<StreamColumnType, number> = {
platform_feed: 60_000,
keyword: 120_000,
account_list: 180_000,
saved_search: 300_000,
};
/** API layer maps this → 422; never stored on failure. */
export class StreamColumnsValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "StreamColumnsValidationError";
}
}
function fail(message: string): never {
throw new StreamColumnsValidationError(message);
}
function asObject(value: unknown, what: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return fail(`${what} must be a JSON object`);
}
return value as Record<string, unknown>;
}
function isControlCharFree(value: string): boolean {
return !/[\u0000-\u001f\u007f]/.test(value);
}
function providerId(value: unknown, what: string): ProviderId {
if (typeof value !== "string" || value.length === 0 || value.length > 64) {
return fail(`${what} must be a 1..64-char provider id`);
}
if (!isControlCharFree(value)) return fail(`${what} must not contain control characters`);
return value;
}
// ---------------------------------------------------------------------------
// spec: platform_feed
// ---------------------------------------------------------------------------
function parsePlatformFeedSpec(value: unknown): PlatformFeedSpec {
const spec = asObject(value, "platform_feed spec");
if (typeof spec["connectionId"] !== "string" || spec["connectionId"].length === 0) {
fail("platform_feed: connectionId must be a non-empty string");
}
if (spec["connectionId"].length > 200) {
fail("platform_feed: connectionId exceeds 200 chars");
}
const rawKinds = spec["kinds"];
const kinds: PlatformFeedKind[] =
rawKinds === undefined ? ["home"] : parseKinds(rawKinds);
return { connectionId: spec["connectionId"], kinds };
}
function parseKinds(value: unknown): PlatformFeedKind[] {
if (!Array.isArray(value)) {
fail("platform_feed: kinds must be a non-empty array of \"home\"|\"mentions\"");
}
const kinds: PlatformFeedKind[] = [];
for (const kind of value) {
if (kind !== "home" && kind !== "mentions") {
fail("platform_feed: kinds may contain only \"home\"|\"mentions\"");
}
if (!kinds.includes(kind)) kinds.push(kind);
}
if (kinds.length === 0) {
fail("platform_feed: kinds must be a non-empty array of \"home\"|\"mentions\"");
}
return kinds;
}
// ---------------------------------------------------------------------------
// spec: keyword / saved_search — one engine, two labels (design §1.1)
// ---------------------------------------------------------------------------
function parseKeywordSpec(value: unknown): KeywordSpec {
const spec = asObject(value, "keyword spec");
const rawQuery = spec["query"];
if (typeof rawQuery !== "string") fail("keyword: query must be a string");
const query = rawQuery.trim();
if (query.length < 1) fail("keyword: query must be 1..200 chars after trimming");
if (query.length > QUERY_MAX) fail(`keyword: query exceeds ${QUERY_MAX} chars after trimming`);
if (!isControlCharFree(query)) {
fail("keyword: query must not contain NUL/control characters");
}
const platforms = parsePlatforms(spec["platforms"]);
const matchMode = spec["matchMode"];
if (matchMode !== undefined && matchMode !== "any") {
fail("keyword: matchMode must be \"any\" (no boolean operators in v1)");
}
return { query, platforms, matchMode: "any" };
}
function parsePlatforms(value: unknown): ProviderId[] {
if (!Array.isArray(value) || value.length < 1 || value.length > PLATFORMS_MAX) {
fail(`keyword: platforms must be an array of 1..${PLATFORMS_MAX} provider ids`);
}
const platforms: ProviderId[] = [];
for (const platform of value) {
const id = providerId(platform, "keyword: platform");
if (!platforms.includes(id)) platforms.push(id); // dedupe silently
}
return platforms;
}
// ---------------------------------------------------------------------------
// spec: account_list
// ---------------------------------------------------------------------------
function parseAccountListSpec(value: unknown): AccountListSpec {
const spec = asObject(value, "account_list spec");
const rawAccounts = spec["accounts"];
if (!Array.isArray(rawAccounts) || rawAccounts.length < 1 || rawAccounts.length > ACCOUNTS_MAX) {
fail(`account_list: accounts must be an array of 1..${ACCOUNTS_MAX} entries`);
}
const accounts: AccountListEntry[] = [];
for (const entry of rawAccounts) {
const record = asObject(entry, "account_list: account");
const provider = providerId(record["provider"], "account_list: provider");
const handle = record["handle"];
if (typeof handle !== "string") fail("account_list: handle must be a string");
const trimmed = handle.trim();
if (trimmed.length < 1 || trimmed.length > 200) {
fail("account_list: handle must be 1..200 chars after trimming");
}
if (!isControlCharFree(trimmed)) {
fail("account_list: handle must not contain NUL/control characters");
}
accounts.push({ provider, handle: trimmed });
}
return { accounts };
}
// ---------------------------------------------------------------------------
// refresh_policy
// ---------------------------------------------------------------------------
function parseRefreshPolicyObject(value: unknown): RefreshPolicy {
const result: RefreshPolicy = {};
if (value === undefined || value === null) return result; // column default '{}'
const policy = asObject(value, "refresh_policy");
if (policy["intervalMs"] !== undefined) {
const intervalMs = policy["intervalMs"];
if (typeof intervalMs !== "number") {
fail("refresh_policy: intervalMs must be a number");
}
result.intervalMs = clampIntervalMs(intervalMs); // non-finite -> floor
}
if (policy["paused"] !== undefined) {
if (typeof policy["paused"] !== "boolean") {
fail("refresh_policy: paused must be a boolean");
}
result.paused = policy["paused"];
}
return result;
}
/** Clamp to the design §1.2 window 60_000..3_600_000 (rounded, finite). */
export function clampIntervalMs(intervalMs: number): number {
if (Number.isNaN(intervalMs)) return INTERVAL_FLOOR;
return Math.min(Math.max(Math.round(intervalMs), INTERVAL_FLOOR), INTERVAL_CEIL);
}
/** Effective cadence when the user set none (design §1.2 defaults). */
export function defaultIntervalMs(type: StreamColumnType): number {
return DEFAULT_INTERVAL_MS[type];
}
// ---------------------------------------------------------------------------
// Public entry points
// ---------------------------------------------------------------------------
/** Maps a column type to its exact persisted spec shape (design §1.2). */
export type SpecForType<T extends StreamColumnType> =
T extends "platform_feed" ? PlatformFeedSpec
: T extends "account_list" ? AccountListSpec
: KeywordSpec; // keyword + saved_search share the keyword engine (§1.1)
/**
* Validate + clamp a `spec` for a column type. Throws
* StreamColumnsValidationError (→ 422 at the API boundary); returns the
* canonical clamped form to persist as JSONB, typed per column type.
*/
export function parseStreamColumnSpec<T extends StreamColumnType>(
type: T,
value: unknown,
): SpecForType<T> {
switch (type) {
case "platform_feed":
return parsePlatformFeedSpec(value) as SpecForType<T>;
case "keyword":
case "saved_search":
return parseKeywordSpec(value) as SpecForType<T>;
case "account_list":
return parseAccountListSpec(value) as SpecForType<T>;
}
}
/**
* Validate + clamp `refresh_policy` (default `{}`). intervalMs clamps to
* 60_000..3_600_000. The budget-derived floor ("never below the
* per-connection budget interval") is applied by the scheduler on top of
* this; this layer enforces only the global clamp window.
*/
export function parseRefreshPolicyClamped(value: unknown = {}): RefreshPolicy {
return parseRefreshPolicyObject(value);
}
// ---------------------------------------------------------------------------
// Small shared clamps
// ---------------------------------------------------------------------------
export function isKeywordLike(type: StreamColumnType): type is "keyword" | "saved_search" {
return type === "keyword" || type === "saved_search";
}
/** Trim + cap at 80 chars so a persist can never trip the DB CHECK. */
export function clampTitle(title: string): string {
const trimmed = title.trim();
return trimmed.length > STREAM_COLUMN_TITLE_MAX
? trimmed.slice(0, STREAM_COLUMN_TITLE_MAX)
: trimmed;
}
/** Dense-rank positions: finite, non-negative integers. */
export function clampPosition(position: number): number {
if (Number.isNaN(position)) return STREAM_COLUMN_POSITION_DEFAULT;
return Math.min(Math.max(Math.round(position), STREAM_COLUMN_POSITION_DEFAULT), POSITION_MAX);
}

266
packages/core/src/vc.ts Normal file
View file

@ -0,0 +1,266 @@
import type { CredentialRef } from "./connection.js";
/** Virtual Channel / Auto Director domain (Level 2 — models land in WP1, engines in WP9–WP13). */
export type VirtualChannelMode = "automatic" | "manual" | "hybrid";
export type SwitchPolicyName =
| "highest_priority"
| "oldest_live"
| "newest_live"
| "round_robin"
| "operator_required"
| "picture_in_picture"
| "two_up"
| "split_screen"
| "grid";
export interface SwitchPolicy {
name: SwitchPolicyName;
/** LIVE confirmation successes required (default 2). */
liveConfirmations: number;
/** OFFLINE failures required (default 3). */
offlineConfirmations: number;
/** No-media seconds before OFFLINE (default 10). */
noMediaSeconds: number;
/** Minimum ms a selected source is held before switching away. */
minHoldMs: number;
/** Cooldown ms between program switches. */
switchCooldownMs: number;
}
export interface VirtualChannel {
id: string;
workspaceId: string;
name: string;
mode: VirtualChannelMode;
outputProfileId: string;
fallbackSceneId?: string;
switchPolicy: SwitchPolicy;
createdAt: Date;
updatedAt: Date;
}
export type LiveSourceStateType =
| "OFFLINE"
| "UNKNOWN"
| "PROBING"
| "STARTING"
| "LIVE"
| "UNSTABLE"
| "ENDING"
| "ERROR";
export interface VirtualChannelSource {
id: string;
virtualChannelId: string;
provider: string;
connectionId?: string;
channelId?: string;
externalUrl?: string;
priority: number;
enabled: boolean;
autoSelect: boolean;
label?: string;
metadata: Record<string, unknown>;
}
export interface LiveSourceState {
sourceId: string;
state: LiveSourceStateType;
detectedAt?: Date;
liveSince?: Date;
lastHealthyAt?: Date;
streamUrl?: string;
latencyMs?: number;
metadata: Record<string, unknown>;
}
export interface SourceMonitorState {
sourceId: string;
current: LiveSourceState;
history: readonly LiveSourceState[];
}
export type SceneLayout =
| "single"
| "pip"
| "two_up"
| "split_horizontal"
| "split_vertical"
| "grid"
| "custom";
export type CropMode = "contain" | "cover" | "crop";
export type SlotAudio = "off" | "on" | "primary";
export interface SceneSlot {
sourceId?: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
cropMode: CropMode;
audio: SlotAudio;
}
export interface ProductionScene {
id: string;
virtualChannelId: string;
name: string;
layout: SceneLayout;
slots: SceneSlot[];
metadata: Record<string, unknown>;
}
export interface ProductionBus {
previewSceneId?: string;
programSceneId: string;
}
export interface DirectorCondition {
field: "live_source_count" | "max_priority" | "source_state";
op: ">=" | "<=" | "==" | ">";
value: number | string;
}
export interface DirectorAction {
layout: SceneLayout;
/** Source selection hint: "top_priority" | "all_live" | "oldest_live" | explicit sourceIds. */
selection: "top_priority" | "all_live" | "oldest_live" | "explicit";
sourceIds?: string[];
}
export interface AutoDirectorRule {
id: string;
virtualChannelId: string;
priority: number;
condition: DirectorCondition;
action: DirectorAction;
}
export type MediaWorkerState =
| "STOPPED"
| "STARTING"
| "RUNNING"
| "DEGRADED"
| "RESTARTING"
| "FAILED";
export interface MediaWorker {
id: string;
virtualChannelId: string;
host: string;
state: MediaWorkerState;
desiredState: MediaWorkerState;
lastHeartbeatAt?: Date;
}
export interface OutputProfile {
id: string;
name: string;
width: number;
height: number;
fps: number;
videoCodec: string;
videoBitrate: number;
audioCodec: string;
audioBitrate: number;
sampleRate: number;
}
/**
* Platform-side live target owned by the live domain (WP6, proposal §4.4).
* Owns the provider, the ingest endpoint URL and the vaulted ingest credential
* reference — and never references media outputs (aggregate boundary vs
* StreamOutput): the inbound FK runs StreamOutput -> destination only.
* `ingestCredential` is an opaque vault reference; raw stream keys never
* appear in this type by design.
*/
export interface LiveStreamDestination {
id: string;
workspaceId: string;
label: string;
provider: string;
/** Ingest endpoint (rtmp/rtmps/srt push URL). */
endpointUrl: string;
/** Vault reference to the ingest credential (stream key); absent for keyless targets. */
ingestCredential?: CredentialRef;
/** Derived, explicitly-stale platform state; runtime truth is the monitor. */
lastPlatformState: "unknown" | "created" | "waiting" | "live" | "ended";
createdAt: Date;
updatedAt: Date;
}
/** Exactly one target: a destination id, or the output's own generic credential handle. */
export type StreamOutputTarget =
| { kind: "destination"; destinationId: string }
| { kind: "handle"; credentialHandleId: string };
export interface StreamOutput {
id: string;
virtualChannelId: string;
connectorId: string;
connectionId?: string;
/** Mutually exclusive with `credentialHandleId`; use `target` for the closed union. */
destinationId?: string;
/** Mutually exclusive with `destinationId`; an opaque vault handle, never a secret. */
credentialHandleId?: string;
enabled: boolean;
}
export function streamOutputTarget(output: Pick<StreamOutput, "destinationId" | "credentialHandleId">): StreamOutputTarget | null {
if (output.destinationId !== undefined && output.credentialHandleId !== undefined) {
throw new Error("StreamOutput cannot reference both a destination and its own credential handle");
}
if (output.destinationId !== undefined) return { kind: "destination", destinationId: output.destinationId };
if (output.credentialHandleId !== undefined) return { kind: "handle", credentialHandleId: output.credentialHandleId };
return null;
}
export interface LiveStream {
id: string;
provider: string;
connectionId: string;
remoteId: string;
title: string;
state: "created" | "waiting" | "live" | "ended";
startedAt?: Date;
endedAt?: Date;
streamKeyRef?: { readonly kind: "credential-ref"; readonly id: string };
rtmpUrl?: string;
playbackUrls: string[];
}
export interface LiveChatMessage {
id: string;
provider: string;
liveStreamId: string;
authorHandle: string;
text: string;
sentAt: Date;
isModerated: boolean;
}
export interface LiveChatParticipant {
handle: string;
role: "viewer" | "moderator" | "owner" | "bot";
}
/** Anti-flapping defaults (proposal §5.2 / reducer hysteresis). */
export const DEFAULT_SWITCH_POLICY: SwitchPolicy = {
name: "highest_priority",
liveConfirmations: 2,
offlineConfirmations: 3,
noMediaSeconds: 10,
minHoldMs: 2_000,
switchCooldownMs: 10_000,
};
/** TAKE holds per its release mode; LOCK holds until released, expired or revoked. */
export type DirectorOverrideKind = "take" | "lock";
/** WP10 manual override release modes (`timed` = expiresAt, default 5 min). */
export type DirectorOverrideRelease = "until_released" | "timed" | "until_source_offline" | "until_next_event";
export type DirectorOverrideReleaseReason = "AUTHZ_REVOKED" | "OVERRIDE_EXPIRED" | "SOURCE_OFFLINE" | "NEXT_EVENT";
export type DirectorMode = "AUTO" | "MANUAL" | "LOCKED" | "FALLBACK";

View file

@ -0,0 +1,42 @@
export interface VideoAsset {
id: string;
workspaceId: string;
storageKey: string;
mimeType: string;
sizeBytes: number;
width?: number;
height?: number;
durationMs?: number;
frameRate?: number;
codec?: string;
audioCodec?: string;
thumbnailKey?: string;
}
export type VideoProcessingJobState =
| "queued"
| "probing"
| "transcoding"
| "complete"
| "failed";
export interface VideoProcessingJob {
id: string;
workspaceId: string;
videoAssetId: string;
state: VideoProcessingJobState;
progressPercent?: number;
outputKey?: string;
error?: string;
}
export interface VideoLibraryEntry {
videoAsset: VideoAsset;
processingState: VideoProcessingJobState;
publications: Array<{
connectionId: string;
remoteId: string;
platform: string;
publishedAt: Date;
}>;
}

View file

@ -0,0 +1,82 @@
/** Identity & RBAC primitives. Roles are bundles of permission strings. */
export const PERMISSIONS = [
"workspace.view",
"workspace.settings",
"workspace.members.manage",
"connection.view",
"connection.manage",
"credential.manage",
"draft.view",
"draft.create",
"draft.edit",
"approval.request",
"approval.grant",
"publication.publish",
"publication.schedule",
"publication.delete",
"media.view",
"media.manage",
"inbox.view",
"inbox.reply",
"stream.view",
"analytics.view",
"audit.view",
"virtualchannel.view",
"virtualchannel.create",
"virtualchannel.edit",
"director.view",
"director.take",
"director.override_auto",
"director.change_layout",
"director.lock_program",
"streamsource.manage",
"streamoutput.manage",
"livestream.view",
"livestream.manage",
"livechat.view",
"livechat.send",
"livechat.moderate",
] as const;
export type Permission = (typeof PERMISSIONS)[number];
export type RoleName =
| "Owner"
| "Admin"
| "Publisher"
| "Editor"
| "Approver"
| "Analyst"
| "Moderator"
| "VideoPublisher"
| "StreamOperator"
| "BroadcastOperator";
export interface Role {
name: RoleName | string;
permissions: readonly Permission[];
}
export interface User {
id: string;
externalId: string;
displayName: string;
email: string;
createdAt: Date;
}
export interface Workspace {
id: string;
name: string;
slug: string;
createdAt: Date;
}
export interface Membership {
id: string;
workspaceId: string;
userId: string;
role: RoleName;
createdAt: Date;
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}