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/.
201 lines
8.8 KiB
TypeScript
201 lines
8.8 KiB
TypeScript
/**
|
|
* 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",
|
|
},
|
|
};
|