145 lines
6.3 KiB
TypeScript
145 lines
6.3 KiB
TypeScript
import type {
|
|
MediaSurface,
|
|
MediaSurfaceAcl,
|
|
MediaSurfaceSource,
|
|
MediaSurfaceState,
|
|
MediaViewerContext,
|
|
} from "./types.ts";
|
|
|
|
const ID = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
|
const AUDIENCES = new Set(["public", "authenticated", "office-members", "allowlist"]);
|
|
const SOURCE_KINDS = new Set(["live-stream", "recording"]);
|
|
const PRIVACY = new Set(["public", "private"]);
|
|
const STATUSES = new Set(["idle", "presenting", "reconnecting", "stopped", "revoked"]);
|
|
|
|
export function parseMediaSurface(value: unknown): MediaSurface {
|
|
const surface = record(value, "surface", [
|
|
"screenId", "officeId", "levelId", "roomId", "acl", "source", "state",
|
|
]);
|
|
const parsed: MediaSurface = {
|
|
screenId: id(surface.screenId, "screenId"),
|
|
officeId: id(surface.officeId, "officeId"),
|
|
levelId: id(surface.levelId, "levelId"),
|
|
roomId: surface.roomId === null ? null : id(surface.roomId, "roomId"),
|
|
acl: parseAcl(surface.acl),
|
|
source: surface.source === null ? null : parseSource(surface.source),
|
|
state: parseState(surface.state),
|
|
};
|
|
const live = parsed.state.status === "presenting" || parsed.state.status === "reconnecting";
|
|
if (live !== (parsed.source !== null)) {
|
|
throw new TypeError("presenting/reconnecting surfaces require a source; all other states forbid one");
|
|
}
|
|
if (parsed.source?.privacy === "private" && parsed.acl.audience === "public") {
|
|
throw new TypeError("a private source cannot have a public audience");
|
|
}
|
|
if (live !== (parsed.state.presenterId !== null)) {
|
|
throw new TypeError("presenting/reconnecting surfaces require a presenter; all other states forbid one");
|
|
}
|
|
if (parsed.state.presenterId && !parsed.acl.presenterIds.includes(parsed.state.presenterId)) {
|
|
throw new TypeError("current presenter is not permitted by acl.presenterIds");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function isMediaSurface(value: unknown): value is MediaSurface {
|
|
try {
|
|
parseMediaSurface(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function parseMediaSource(value: unknown): MediaSurfaceSource {
|
|
return parseSource(value);
|
|
}
|
|
|
|
/** Validate the trusted context assembled by a server/session boundary. */
|
|
export function parseMediaViewerContext(value: unknown): MediaViewerContext {
|
|
const viewer = record(value, "viewer", ["viewerId", "officeIds", "optedIn"]);
|
|
if (viewer.viewerId !== null && typeof viewer.viewerId !== "string") {
|
|
throw new TypeError("viewer.viewerId is invalid");
|
|
}
|
|
if (typeof viewer.optedIn !== "boolean") throw new TypeError("viewer.optedIn must be boolean");
|
|
return {
|
|
viewerId: viewer.viewerId === null ? null : id(viewer.viewerId, "viewer.viewerId"),
|
|
officeIds: ids(viewer.officeIds, "viewer.officeIds"),
|
|
optedIn: viewer.optedIn,
|
|
};
|
|
}
|
|
|
|
function parseAcl(value: unknown): MediaSurfaceAcl {
|
|
const acl = record(value, "acl", ["audience", "viewerIds", "presenterIds", "moderatorIds"]);
|
|
if (typeof acl.audience !== "string" || !AUDIENCES.has(acl.audience)) {
|
|
throw new TypeError("acl.audience is invalid");
|
|
}
|
|
const parsed: MediaSurfaceAcl = {
|
|
audience: acl.audience as MediaSurfaceAcl["audience"],
|
|
viewerIds: ids(acl.viewerIds, "acl.viewerIds"),
|
|
presenterIds: ids(acl.presenterIds, "acl.presenterIds"),
|
|
moderatorIds: ids(acl.moderatorIds, "acl.moderatorIds"),
|
|
};
|
|
if (parsed.audience === "allowlist" && parsed.viewerIds.length === 0) {
|
|
throw new TypeError("an allowlist audience requires acl.viewerIds");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseSource(value: unknown): MediaSurfaceSource {
|
|
const source = record(value, "source", ["kind", "locator", "privacy", "hasAudio", "autoplay", "muted"]);
|
|
if (typeof source.kind !== "string" || !SOURCE_KINDS.has(source.kind)) throw new TypeError("source.kind is invalid");
|
|
if (typeof source.privacy !== "string" || !PRIVACY.has(source.privacy)) throw new TypeError("source.privacy is invalid");
|
|
if (typeof source.locator !== "string" || source.locator.length < 1 || source.locator.length > 2048) {
|
|
throw new TypeError("source.locator must be a non-empty string of at most 2048 characters");
|
|
}
|
|
if (typeof source.hasAudio !== "boolean") throw new TypeError("source.hasAudio must be boolean");
|
|
if (source.autoplay !== false) throw new TypeError("media sources must set autoplay to false");
|
|
if (source.muted !== true) throw new TypeError("media sources must start muted");
|
|
return {
|
|
kind: source.kind as MediaSurfaceSource["kind"],
|
|
locator: source.locator,
|
|
privacy: source.privacy as MediaSurfaceSource["privacy"],
|
|
hasAudio: source.hasAudio,
|
|
autoplay: false,
|
|
muted: true,
|
|
};
|
|
}
|
|
|
|
function parseState(value: unknown): MediaSurfaceState {
|
|
const state = record(value, "state", ["status", "presenterId", "revision"]);
|
|
if (typeof state.status !== "string" || !STATUSES.has(state.status)) throw new TypeError("state.status is invalid");
|
|
if (state.presenterId !== null && typeof state.presenterId !== "string") throw new TypeError("state.presenterId is invalid");
|
|
if (!Number.isSafeInteger(state.revision) || (state.revision as number) < 0) {
|
|
throw new TypeError("state.revision must be a non-negative safe integer");
|
|
}
|
|
return {
|
|
status: state.status as MediaSurfaceState["status"],
|
|
presenterId: state.presenterId === null ? null : id(state.presenterId, "state.presenterId"),
|
|
revision: state.revision as number,
|
|
};
|
|
}
|
|
|
|
function record(value: unknown, name: string, keys: readonly string[]): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) {
|
|
throw new TypeError(`${name} must be a plain JSON object`);
|
|
}
|
|
const result = value as Record<string, unknown>;
|
|
const actual = Object.keys(result);
|
|
if (actual.length !== keys.length || actual.some((key) => !keys.includes(key))) {
|
|
throw new TypeError(`${name} must contain exactly: ${keys.join(", ")}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function id(value: unknown, name: string): string {
|
|
if (typeof value !== "string" || !ID.test(value)) throw new TypeError(`${name} is not a valid id`);
|
|
return value;
|
|
}
|
|
|
|
function ids(value: unknown, name: string): string[] {
|
|
if (!Array.isArray(value) || value.length > 1024) throw new TypeError(`${name} must be an array`);
|
|
const result = value.map((item, index) => id(item, `${name}[${index}]`));
|
|
if (new Set(result).size !== result.length) throw new TypeError(`${name} contains duplicates`);
|
|
return result;
|
|
}
|