import type { MediaAuthorizationDecision, MediaAuthorizationReason, MediaSurface, MediaSurfaceEvent, MediaSurfaceSource, MediaViewerContext, } from "./types.ts"; import { parseMediaSource, parseMediaSurface, parseMediaViewerContext } from "./validation.ts"; /** * Produce the response a server may return to this viewer. The source locator * is copied only after authorization, presentation state, and explicit opt-in. */ export function authorizeMediaSurface( input: MediaSurface, viewer: MediaViewerContext, ): MediaAuthorizationDecision { const surface = parseMediaSurface(input); const context = parseMediaViewerContext(viewer); const access = audienceDecision(surface, context); const presenting = surface.state.status === "presenting" && surface.source !== null; let reason: MediaAuthorizationReason = access.reason; if (access.authorized && !presenting) reason = "not_presenting"; else if (access.authorized && presenting && !context.optedIn) reason = "opt_in_required"; else if (access.authorized && presenting && context.optedIn) reason = "ready"; const canView = reason === "ready"; return { authorized: access.authorized, optedIn: context.optedIn, canView, reason, surface: { screenId: surface.screenId, officeId: surface.officeId, levelId: surface.levelId, roomId: surface.roomId, status: surface.state.status, source: canView && surface.source ? { ...surface.source } : null, playback: { autoplay: false, muted: true }, }, }; } /** Strict state machine for presenter stop/revoke/disconnect/reconnect flows. */ export function transitionMediaSurface(input: MediaSurface, event: MediaSurfaceEvent): MediaSurface { const surface = parseMediaSurface(input); switch (event.type) { case "present": { requirePresenter(surface, event.presenterId); const source = parseMediaSource(event.source); if (source.privacy === "private" && surface.acl.audience === "public") { throw new Error("a private source cannot be presented to a public audience"); } return next(surface, "presenting", event.presenterId, source); } case "stop": requireCurrent(surface, event.presenterId); return next(surface, "stopped", null, null); case "revoke": if (event.actorId !== surface.state.presenterId && !surface.acl.moderatorIds.includes(event.actorId)) { throw new Error("only the current presenter or a moderator may revoke a surface"); } return next(surface, "revoked", null, null); case "disconnect": requireCurrent(surface, event.presenterId); if (surface.state.status !== "presenting") throw new Error("only a presenting surface can disconnect"); return next(surface, "reconnecting", event.presenterId, surface.source); case "reconnect": requireCurrent(surface, event.presenterId); if (surface.state.status !== "reconnecting") throw new Error("only a reconnecting surface can reconnect"); return next( surface, "presenting", event.presenterId, event.source === undefined ? surface.source : parseMediaSource(event.source), ); } } function audienceDecision( surface: MediaSurface, viewer: MediaViewerContext, ): { authorized: boolean; reason: MediaAuthorizationReason } { const id = viewer.viewerId; switch (surface.acl.audience) { case "public": return { authorized: true, reason: "ready" }; case "authenticated": return id ? { authorized: true, reason: "ready" } : { authorized: false, reason: "authentication_required" }; case "office-members": return id && viewer.officeIds.includes(surface.officeId) ? { authorized: true, reason: "ready" } : { authorized: false, reason: id ? "office_membership_required" : "authentication_required" }; case "allowlist": return id && surface.acl.viewerIds.includes(id) ? { authorized: true, reason: "ready" } : { authorized: false, reason: id ? "not_allowlisted" : "authentication_required" }; } } function requirePresenter(surface: MediaSurface, presenterId: string): void { if (!surface.acl.presenterIds.includes(presenterId)) throw new Error("presenter is not permitted by this surface"); } function requireCurrent(surface: MediaSurface, presenterId: string): void { if (surface.state.presenterId !== presenterId) throw new Error("caller is not the current presenter"); } function next( surface: MediaSurface, status: MediaSurface["state"]["status"], presenterId: string | null, source: MediaSurfaceSource | null, ): MediaSurface { return parseMediaSurface({ ...surface, acl: { ...surface.acl, viewerIds: [...surface.acl.viewerIds], presenterIds: [...surface.acl.presenterIds], moderatorIds: [...surface.acl.moderatorIds], }, source: source ? { ...source } : null, state: { status, presenterId, revision: surface.state.revision + 1 }, }); }