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
17
packages/core/package.json
Normal file
17
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
packages/core/src/capabilities.ts
Normal file
49
packages/core/src/capabilities.ts
Normal 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;
|
||||
}
|
||||
201
packages/core/src/chat-contract.schema.ts
Normal file
201
packages/core/src/chat-contract.schema.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
108
packages/core/src/chat-contract.test.ts
Normal file
108
packages/core/src/chat-contract.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
302
packages/core/src/chat-contract.ts
Normal file
302
packages/core/src/chat-contract.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
51
packages/core/src/connection.ts
Normal file
51
packages/core/src/connection.ts
Normal 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;
|
||||
}
|
||||
27
packages/core/src/index.ts
Normal file
27
packages/core/src/index.ts
Normal 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";
|
||||
47
packages/core/src/messaging.ts
Normal file
47
packages/core/src/messaging.ts
Normal 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;
|
||||
}
|
||||
7
packages/core/src/publishing-execution.ts
Normal file
7
packages/core/src/publishing-execution.ts
Normal 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`;
|
||||
}
|
||||
109
packages/core/src/publishing.ts
Normal file
109
packages/core/src/publishing.ts
Normal 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;
|
||||
}
|
||||
37
packages/core/src/shared-files.test.ts
Normal file
37
packages/core/src/shared-files.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
109
packages/core/src/shared-files.ts
Normal file
109
packages/core/src/shared-files.ts
Normal 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>;
|
||||
}
|
||||
89
packages/core/src/social.ts
Normal file
89
packages/core/src/social.ts
Normal 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;
|
||||
}
|
||||
225
packages/core/src/stream-columns.test.ts
Normal file
225
packages/core/src/stream-columns.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
304
packages/core/src/stream-columns.ts
Normal file
304
packages/core/src/stream-columns.ts
Normal 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
266
packages/core/src/vc.ts
Normal 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";
|
||||
42
packages/core/src/video.ts
Normal file
42
packages/core/src/video.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
82
packages/core/src/workspace.ts
Normal file
82
packages/core/src/workspace.ts
Normal 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;
|
||||
}
|
||||
8
packages/core/tsconfig.json
Normal file
8
packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue