feat: add private profile media and realtime contracts
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user