feat: add private profile media and realtime contracts
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
export * from "./types.ts";
|
||||
export {
|
||||
isMediaSurface,
|
||||
parseMediaSource,
|
||||
parseMediaSurface,
|
||||
parseMediaViewerContext,
|
||||
} from "./validation.ts";
|
||||
export { authorizeMediaSurface, transitionMediaSurface } from "./policy.ts";
|
||||
export {
|
||||
createMediaSurfaceLifecycle,
|
||||
type MediaSurfaceBinding,
|
||||
type MediaSurfaceLifecycle,
|
||||
} from "./lifecycle.ts";
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { VideoTexture } from "three";
|
||||
import type { MediaAuthorizationDecision } from "./types.ts";
|
||||
|
||||
export interface MediaSurfaceBinding {
|
||||
/** All objects are caller-owned. This adapter never obtains or fetches them. */
|
||||
video: HTMLVideoElement;
|
||||
texture: VideoTexture;
|
||||
track?: MediaStreamTrack;
|
||||
}
|
||||
|
||||
export interface MediaSurfaceLifecycle {
|
||||
bind(binding: MediaSurfaceBinding): void;
|
||||
apply(decision: MediaAuthorizationDecision): void;
|
||||
texture(): VideoTexture | null;
|
||||
optedIn(): boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate an already-created video texture behind a server decision and explicit
|
||||
* viewer consent. `play`, `getUserMedia`, `fetch`, `track.stop`, and
|
||||
* `texture.dispose` are intentionally absent: acquisition and ownership remain
|
||||
* with the caller.
|
||||
*/
|
||||
export function createMediaSurfaceLifecycle(): MediaSurfaceLifecycle {
|
||||
let binding: MediaSurfaceBinding | null = null;
|
||||
let allowed = false;
|
||||
let consent = false;
|
||||
let disposed = false;
|
||||
|
||||
function silence(video: HTMLVideoElement): void {
|
||||
video.autoplay = false;
|
||||
video.muted = true;
|
||||
video.pause();
|
||||
}
|
||||
|
||||
return {
|
||||
bind(next) {
|
||||
if (disposed) throw new Error("media surface lifecycle is disposed");
|
||||
if (binding) silence(binding.video);
|
||||
binding = next;
|
||||
silence(next.video);
|
||||
},
|
||||
apply(decision) {
|
||||
if (disposed) return;
|
||||
allowed = decision.canView && decision.surface.source !== null;
|
||||
consent = decision.optedIn;
|
||||
if (!allowed && binding) silence(binding.video);
|
||||
},
|
||||
texture: () => (!disposed && allowed && consent ? binding?.texture ?? null : null),
|
||||
optedIn: () => consent,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (binding) silence(binding.video);
|
||||
binding = null;
|
||||
allowed = false;
|
||||
consent = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/** JSON-only contracts for office screen surfaces. No browser or renderer types. */
|
||||
|
||||
export type MediaAudience = "public" | "authenticated" | "office-members" | "allowlist";
|
||||
export type MediaSourcePrivacy = "public" | "private";
|
||||
export type MediaSourceKind = "live-stream" | "recording";
|
||||
export type MediaSurfaceStatus = "idle" | "presenting" | "reconnecting" | "stopped" | "revoked";
|
||||
|
||||
export interface MediaSurfaceAcl {
|
||||
audience: MediaAudience;
|
||||
viewerIds: string[];
|
||||
presenterIds: string[];
|
||||
moderatorIds: string[];
|
||||
}
|
||||
|
||||
export interface MediaSurfaceSource {
|
||||
kind: MediaSourceKind;
|
||||
/** Opaque server-issued locator. Never expose it before authorization and opt-in. */
|
||||
locator: string;
|
||||
privacy: MediaSourcePrivacy;
|
||||
hasAudio: boolean;
|
||||
/** Deliberately literal invariants: a surface never asks a browser to autoplay. */
|
||||
autoplay: false;
|
||||
muted: true;
|
||||
}
|
||||
|
||||
export interface MediaSurfaceState {
|
||||
status: MediaSurfaceStatus;
|
||||
presenterId: string | null;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface MediaSurface {
|
||||
screenId: string;
|
||||
officeId: string;
|
||||
levelId: string;
|
||||
roomId: string | null;
|
||||
acl: MediaSurfaceAcl;
|
||||
source: MediaSurfaceSource | null;
|
||||
state: MediaSurfaceState;
|
||||
}
|
||||
|
||||
export interface MediaViewerContext {
|
||||
/** Trusted server identity. `null` is the anonymous public viewer. */
|
||||
viewerId: string | null;
|
||||
/** Trusted memberships, not values supplied by the browser. */
|
||||
officeIds: string[];
|
||||
/** Viewing never starts merely because authorization succeeded. */
|
||||
optedIn: boolean;
|
||||
}
|
||||
|
||||
export type MediaAuthorizationReason =
|
||||
| "ready"
|
||||
| "not_presenting"
|
||||
| "authentication_required"
|
||||
| "office_membership_required"
|
||||
| "not_allowlisted"
|
||||
| "opt_in_required";
|
||||
|
||||
/** Safe response shape. `source` is null unless both policy and opt-in passed. */
|
||||
export interface MediaSurfaceView {
|
||||
screenId: string;
|
||||
officeId: string;
|
||||
levelId: string;
|
||||
roomId: string | null;
|
||||
status: MediaSurfaceStatus;
|
||||
source: MediaSurfaceSource | null;
|
||||
playback: { autoplay: false; muted: true };
|
||||
}
|
||||
|
||||
export interface MediaAuthorizationDecision {
|
||||
authorized: boolean;
|
||||
optedIn: boolean;
|
||||
canView: boolean;
|
||||
reason: MediaAuthorizationReason;
|
||||
surface: MediaSurfaceView;
|
||||
}
|
||||
|
||||
export type MediaSurfaceEvent =
|
||||
| { type: "present"; presenterId: string; source: MediaSurfaceSource }
|
||||
| { type: "stop"; presenterId: string }
|
||||
| { type: "revoke"; actorId: string }
|
||||
| { type: "disconnect"; presenterId: string }
|
||||
| { type: "reconnect"; presenterId: string; source?: MediaSurfaceSource };
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user