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 { 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; 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; }