128 lines
4.9 KiB
TypeScript
128 lines
4.9 KiB
TypeScript
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 },
|
|
});
|
|
}
|