1
0

feat: add private office media signaling

This commit is contained in:
2026-08-11 20:48:19 -07:00
parent fc1f500019
commit d841575315
14 changed files with 2342 additions and 3 deletions
+39 -3
View File
@@ -35,10 +35,15 @@ without breaking the typecheck, which is the wrong order to find out.
| `POST /api/v1/realtime/events` | — | fetch-streamed SSE, opened with a strict `ResumeRequest` | **private; never cached** |
| `POST /api/v1/realtime/pose` | — | one owned `EntityPoseSnapshot` | **private; never cached** |
| `POST /api/v1/realtime/leave` | — | session id + opaque resume token | **private; never cached** |
| `POST /api/v1/media/join` | — | strict create or explicitly opted-in join request | **private; never cached** |
| `POST /api/v1/media/events` | — | fetch-streamed SSE opened with a strict resume request | **private; never cached** |
| `POST /api/v1/media/signal` | — | strict targeted SDP/ICE request | **private; never cached** |
| `POST /api/v1/media/leave` | — | strict presenter stop or revoke request | **private; never cached** |
Every body is declared once, in `src/server/wire.ts` in the **root** package
type-only, so it compiles to nothing and both the browser build and this service
import the same declarations without either becoming a dependency of the other.
Every body is declared once in the **root** package: ordinary feeds live in
`src/server/wire.ts`, realtime in `src/realtime`, and screen signaling in
`src/media/signalingTypes.ts`. The browser and service import the same strict
contracts without either transport becoming a dependency of the other.
Both location parameters are optional and omitting them answers for the default
region, which is the first entry in `TERA_REGIONS`. Giving both `city` and a
@@ -75,6 +80,37 @@ or room may be joined. The three public demo offices bundled into this repo may
use their office envelope without a duplicate server pack; that exception does
not invent floor/room access and does not apply to arbitrary tenant ids.
### Office screen signaling
Office screen sharing uses a separate, in-memory signaling service; it never
enters the game-state realtime service. The server stores only short-lived SDP
and ICE messages, hashed capability grants and server-only auth subjects. It
does not receive or store media, track data, recordings, stream URLs or source
locators. All capabilities travel in authenticated POST bodies, including the
fetch-streamed SSE request, never in a query string.
A share binds to an exact authored `{officeId, levelId, roomId, screenId}`. The
server resolves the office pack and confirms that the id is a monitor/display
prop at that level and in that room. The only no-disk exception is the exact
screen catalogue in the three public bundled demo packs. A viewer joins by that
binding and must send literal `viewerOptIn: true`; the server finds the active
presenter and issues opaque session/participant ids. One presenter and at most
seven viewers are allowed. Complete participant snapshots let the presenter
create and close one peer connection per authorized viewer.
This is currently an **authenticated deployment-member policy**, because the
existing auth model knows only signed-in member versus configured global admin.
It is not a tenant or per-office membership ACL. Add an authoritative
office-membership provider before using these routes for tenant-isolated offices.
Grants are random, stored only as SHA-256 hashes, rotated on resume and leased
for a short period. Signal queues, sessions, participants and request rates are
bounded. Stop, leave, expiry and revocation remove server capabilities and send
best-effort terminal/snapshot events to connected peers. They cannot instantly
terminate media already flowing through an established WebRTC connection;
clients must close missing/revoked peers, and the short lease bounds disconnected
clients that miss an event.
## Regions
**A caller's coordinate is never forwarded upstream. It only selects among the
+2
View File
@@ -18,6 +18,7 @@ import { loadConfig, type Config } from "./config.ts";
import { registerFlights } from "./routes/flights.ts";
import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
import { registerMedia } from "./routes/media.ts";
import { registerOffices } from "./routes/offices.ts";
import { registerPresence } from "./routes/presence.ts";
import { registerRealtime } from "./routes/realtime.ts";
@@ -50,6 +51,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
registerSatellites(app, services);
registerWeather(app, services);
registerMarkers(app, services);
registerMedia(app, services);
registerOffices(app, services);
registerPresence(app, services);
registerRealtime(app, services);
+41
View File
@@ -0,0 +1,41 @@
/** Server-authoritative lookup for authored office screen surfaces. */
import { Plan } from "../../../src/interiors/plan.ts";
import type { Office } from "../../../src/interiors/types.ts";
import FRONTIER_VALLEY from "../../../src/offices/frontier-valley.ts";
import LUMBRIDGE_HQ from "../../../src/offices/lumbridge-hq.ts";
import MATEO_COURT from "../../../src/offices/mateo-court.ts";
import type { ScreenShareBinding } from "../../../src/media/signalingTypes.ts";
const SCREEN_KINDS = new Set([
"tera:screen.monitor",
"tera:screen.wall-display",
]);
const BUNDLED_OFFICES: ReadonlyMap<string, Office> = new Map(
[LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT].map((office) => [office.id, office]),
);
/**
* A bundled pack is public sample geometry, so its exact authored screen
* catalogue is the only bundled exception. No real/private pack is inferred.
*/
export function bundledMediaOffice(officeId: string): Office | null {
return BUNDLED_OFFICES.get(officeId) ?? null;
}
/**
* Confirm an exact client binding against a validated/resolved office pack.
* Room membership is computed from the prop's authored position rather than
* trusting a caller-provided label.
*/
export function officeHasMediaBinding(office: Office, binding: ScreenShareBinding): boolean {
if (office.id !== binding.officeId) return false;
const plan = new Plan(office, { depth: "full", warn: false });
const level = plan.level(binding.levelId);
if (!level) return false;
const prop = level.props.find((candidate) => candidate.id === binding.screenId);
if (!prop || !SCREEN_KINDS.has(prop.kind)) return false;
const roomId = plan.roomAt(level.id, prop.position)?.id ?? null;
return roomId === binding.roomId;
}
+8
View File
@@ -0,0 +1,8 @@
export {
createMediaSignalService,
type MediaSignalFailureCode,
type MediaSignalResult,
type MediaSignalService,
type MediaSignalServiceOptions,
} from "./service.ts";
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
+406
View File
@@ -0,0 +1,406 @@
/** In-memory WebRTC signaling only: no tracks, media bytes, locators, or persistence. */
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import {
MAX_SCREEN_SHARE_PARTICIPANTS,
SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
type ScreenShareAccessGrant,
type ScreenShareBinding,
type ScreenShareCreateGrant,
type ScreenShareCreateRequest,
type ScreenShareCredential,
type ScreenShareJoinGrant,
type ScreenShareJoinRequest,
type ScreenShareParticipant,
type ScreenSharePeerMessage,
type ScreenShareResumeGrant,
type ScreenShareResumeRequest,
type ScreenShareRevokeRequest,
type ScreenShareRevoked,
type ScreenShareRole,
type ScreenShareSignalRelay,
type ScreenShareSignalRequest,
type ScreenShareStopRequest,
type ScreenShareStopped,
} from "../../../src/media/signalingTypes.ts";
export type MediaSignalFailureCode =
| "invalid" | "unauthorized" | "expired" | "conflict" | "capacity" | "rate-limited";
export type MediaSignalResult<T> =
| { ok: true; value: T }
| { ok: false; code: MediaSignalFailureCode; message: string };
export interface MediaSignalServiceOptions {
now?: () => number;
grantTtlMs?: number;
maximumSessions?: number;
maximumParticipantsPerSession?: number;
maximumQueuedMessagesPerParticipant?: number;
maximumSignalsPerWindow?: number;
rateWindowMs?: number;
}
export interface MediaSignalService {
join(request: ScreenShareCreateRequest | ScreenShareJoinRequest, subject: string): MediaSignalResult<ScreenShareCreateGrant | ScreenShareJoinGrant>;
resume(request: ScreenShareResumeRequest, subject: string): MediaSignalResult<ScreenShareResumeGrant>;
signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null>;
leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null>;
subscribe(credential: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void>;
revalidate(credential: ScreenShareCredential, subject: string): MediaSignalResult<null>;
revokeSubject(subject: string): number;
cleanup(): number;
sessionCount(): number;
}
interface Participant {
id: string;
subject: string;
role: ScreenShareRole;
tokenHash: Buffer;
issuedAt: number;
expiresAt: number;
lastClientSequence: number;
rateStartedAt: number;
rateCount: number;
queue: ScreenSharePeerMessage[];
listeners: Set<(message: ScreenSharePeerMessage) => void>;
}
interface Session {
id: string;
binding: ScreenShareBinding;
sequence: number;
participants: Map<string, Participant>;
}
export function createMediaSignalService(options: MediaSignalServiceOptions = {}): MediaSignalService {
const now = options.now ?? Date.now;
const ttl = positive(options.grantTtlMs, 90_000);
const maximumSessions = count(options.maximumSessions, 128);
const maximumParticipants = Math.min(
MAX_SCREEN_SHARE_PARTICIPANTS,
count(options.maximumParticipantsPerSession, MAX_SCREEN_SHARE_PARTICIPANTS),
);
const maximumQueued = count(options.maximumQueuedMessagesPerParticipant, 64);
const maximumSignals = count(options.maximumSignalsPerWindow, 120);
const rateWindowMs = positive(options.rateWindowMs, 10_000);
const sessions = new Map<string, Session>();
function peerList(session: Session, except: string): ScreenShareParticipant[] {
return [...session.participants.values()]
.filter((participant) => participant.id !== except)
.map(({ id: participantId, role }) => ({ participantId, role }));
}
function nextSequence(session: Session): number {
session.sequence = Math.min(Number.MAX_SAFE_INTEGER, session.sequence + 1);
return session.sequence;
}
function stopNotice(session: Session, reason: ScreenShareStopped["reason"]): ScreenShareStopped {
return { type: "screen-share-stopped", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id,
binding: session.binding, reason };
}
function credential(session: Session, participant: Participant, token: string): ScreenShareCredential {
return { sessionId: session.id, participantId: participant.id, role: participant.role, grantToken: token };
}
function grant(session: Session, participant: Participant, token: string): ScreenShareAccessGrant {
return { credential: credential(session, participant, token), issuedAtMs: participant.issuedAt, expiresAtMs: participant.expiresAt };
}
function emit(participant: Participant, message: ScreenSharePeerMessage): void {
if (participant.listeners.size > 0) {
for (const listener of participant.listeners) listener(message);
return;
}
if (participant.queue.length >= maximumQueued) {
// Resume is deliberately discontinuous and carries a complete peer
// snapshot, so dropping the oldest relay is safer than letting a sender
// evict another participant by filling its queue.
participant.queue.shift();
}
participant.queue.push(message);
}
function participantSnapshot(session: Session): void {
const timestampMs = now();
for (const participant of [...session.participants.values()]) {
emit(participant, {
type: "screen-share-participants",
protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session),
timestampMs,
sessionId: session.id,
binding: session.binding,
participants: peerList(session, participant.id),
leaseExpiresAtMs: participant.expiresAt,
});
}
}
function destroySession(session: Session, notice?: ScreenShareStopped | ScreenShareRevoked): void {
// Terminal messages are best effort for connected peers. Queuing one and
// then immediately destroying the queue would be dishonest, while routing
// through `emit` on a full queue could recurse into participant removal.
if (notice) {
for (const participant of session.participants.values()) {
for (const listener of participant.listeners) listener(notice);
}
}
sessions.delete(session.id);
for (const participant of session.participants.values()) {
participant.tokenHash.fill(0);
participant.queue.length = 0;
participant.listeners.clear();
}
session.participants.clear();
}
function removeParticipant(
session: Session,
participant: Participant,
reason: ScreenShareRevoked["reason"] = "viewer-left",
): void {
const notice: ScreenShareRevoked = {
type: "screen-share-revoked",
protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session),
timestampMs: now(),
sessionId: session.id,
binding: session.binding,
scope: "participant",
targetParticipantId: participant.id,
reason,
reconnectAllowed: false,
};
for (const listener of participant.listeners) listener(notice);
session.participants.delete(participant.id);
participant.tokenHash.fill(0);
participant.queue.length = 0;
participant.listeners.clear();
participantSnapshot(session);
}
function authenticate(raw: ScreenShareCredential, subject: string): MediaSignalResult<{ session: Session; participant: Participant }> {
const session = sessions.get(raw.sessionId);
const participant = session?.participants.get(raw.participantId);
if (!session || !participant || participant.subject !== subject || participant.role !== raw.role ||
!tokenMatches(participant.tokenHash, raw.grantToken)) {
return { ok: false, code: "unauthorized", message: "Media signaling credential is invalid." };
}
if (now() >= participant.expiresAt) {
if (participant.role === "presenter") {
destroySession(session, stopNotice(session, "grant-expired"));
} else removeParticipant(session, participant, "authorization-revoked");
return { ok: false, code: "expired", message: "Media signaling grant expired." };
}
return { ok: true, value: { session, participant } };
}
function validateRequest(
session: Session,
participant: Participant,
binding: ScreenShareBinding,
sequence: number,
): MediaSignalResult<null> {
if (!sameBinding(session.binding, binding)) return { ok: false, code: "unauthorized", message: "Media binding does not match the grant." };
if (!Number.isSafeInteger(sequence) || sequence <= participant.lastClientSequence) {
return { ok: false, code: "conflict", message: "Media client sequence is not monotonic." };
}
participant.lastClientSequence = sequence;
return { ok: true, value: null };
}
function newParticipant(subject: string, role: ScreenShareRole, clientSequence: number): { participant: Participant; token: string } {
const token = opaque(32);
const issuedAt = now();
return { token, participant: {
id: opaque(18), subject, role, tokenHash: hash(token), issuedAt, expiresAt: issuedAt + ttl,
lastClientSequence: clientSequence, rateStartedAt: issuedAt, rateCount: 0, queue: [], listeners: new Set(),
} };
}
function join(request: ScreenShareCreateRequest | ScreenShareJoinRequest, subject: string): MediaSignalResult<ScreenShareCreateGrant | ScreenShareJoinGrant> {
cleanup();
if (request.type === "screen-share-create-request") {
if (sessions.size >= maximumSessions) return { ok: false, code: "capacity", message: "Media signaling is at capacity." };
if ([...sessions.values()].some((candidate) => sameBinding(candidate.binding, request.binding))) {
return { ok: false, code: "conflict", message: "That screen already has an active presenter." };
}
const session: Session = { id: opaque(18), binding: copyBinding(request.binding), sequence: 0, participants: new Map() };
const made = newParticipant(subject, "presenter", request.sequence);
session.participants.set(made.participant.id, made.participant);
sessions.set(session.id, session);
return { ok: true, value: {
type: "screen-share-create-grant", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
requestId: request.requestId, sequence: nextSequence(session), timestampMs: now(), binding: session.binding,
grant: grant(session, made.participant, made.token) as ScreenShareAccessGrant<"presenter">,
nextClientSequence: request.sequence + 1, participants: [],
} };
}
const session = [...sessions.values()].find((candidate) => sameBinding(candidate.binding, request.binding));
if (!session) return { ok: false, code: "conflict", message: "That screen has no active presenter." };
if (session.participants.size >= maximumParticipants) return { ok: false, code: "capacity", message: "Media screen is at capacity." };
if ([...session.participants.values()].some((participant) => participant.subject === subject)) {
return { ok: false, code: "conflict", message: "Member is already active on that screen." };
}
const made = newParticipant(subject, "viewer", request.sequence);
const existing = peerList(session, made.participant.id);
session.participants.set(made.participant.id, made.participant);
participantSnapshot(session);
return { ok: true, value: {
type: "screen-share-join-grant", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
requestId: request.requestId, sequence: nextSequence(session), timestampMs: now(), binding: session.binding,
grant: grant(session, made.participant, made.token) as ScreenShareAccessGrant<"viewer">,
nextClientSequence: request.sequence + 1, participants: existing,
} };
}
function resume(request: ScreenShareResumeRequest, subject: string): MediaSignalResult<ScreenShareResumeGrant> {
const found = authenticate(request.credential, subject);
if (!found.ok) return found;
const { session, participant } = found.value;
const valid = validateRequest(session, participant, request.binding, request.sequence);
if (!valid.ok) return valid;
const token = opaque(32);
participant.tokenHash.fill(0);
participant.tokenHash = hash(token);
participant.issuedAt = now();
participant.expiresAt = now() + ttl;
// A resume grant carries a complete participant snapshot. Dropping queued
// relays avoids replaying a message with a sequence older than that grant;
// clients renegotiate when `continuous` is false.
participant.queue.length = 0;
return { ok: true, value: {
type: "screen-share-resume-grant", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
requestId: request.requestId, sequence: nextSequence(session), timestampMs: now(), binding: session.binding,
grant: grant(session, participant, token), nextClientSequence: request.sequence + 1,
participants: peerList(session, participant.id), continuous: false,
} };
}
function signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null> {
const found = authenticate(request.credential, subject);
if (!found.ok) return found;
const { session, participant } = found.value;
const valid = validateRequest(session, participant, request.binding, request.sequence);
if (!valid.ok) return valid;
if (now() - participant.rateStartedAt >= rateWindowMs) { participant.rateStartedAt = now(); participant.rateCount = 0; }
if (++participant.rateCount > maximumSignals) return { ok: false, code: "rate-limited", message: "Media signaling rate exceeded." };
const target = session.participants.get(request.targetParticipantId);
if (!target || target.role === participant.role) return { ok: false, code: "unauthorized", message: "Media signaling target is not permitted." };
if (request.signal.kind === "sdp") {
const permitted = participant.role === "presenter" ? request.signal.descriptionType === "offer" : request.signal.descriptionType === "answer";
if (!permitted) return { ok: false, code: "unauthorized", message: "Media SDP role is not permitted." };
}
const relay: ScreenShareSignalRelay = {
type: "screen-share-signal-relay", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding,
fromParticipantId: participant.id, targetParticipantId: target.id, signal: request.signal,
};
emit(target, relay);
return { ok: true, value: null };
}
function leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null> {
const found = authenticate(request.credential, subject);
if (!found.ok) return found;
const { session, participant } = found.value;
const valid = validateRequest(session, participant, request.binding, request.sequence);
if (!valid.ok) return valid;
if (request.type === "screen-share-stop-request") {
if (participant.role !== "presenter") return { ok: false, code: "unauthorized", message: "Only a presenter may stop a share." };
destroySession(session, stopNotice(session, request.reason));
return { ok: true, value: null };
}
if (request.reason === "moderator-action" && !admin) return { ok: false, code: "unauthorized", message: "Moderator action requires an administrator." };
if (request.scope === "session") {
if (participant.role !== "presenter" && !admin) return { ok: false, code: "unauthorized", message: "Only a presenter or administrator may revoke a session." };
destroySession(session, {
type: "screen-share-revoked", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding,
scope: "session", targetParticipantId: null, reason: request.reason, reconnectAllowed: false,
});
return { ok: true, value: null };
}
const targetId = request.targetParticipantId ?? participant.id;
const target = session.participants.get(targetId);
if (!target) return { ok: false, code: "invalid", message: "Media participant is unavailable." };
if (target.id !== participant.id && participant.role !== "presenter" && !admin) {
return { ok: false, code: "unauthorized", message: "Viewer may only leave its own session." };
}
if (target.role === "presenter") return { ok: false, code: "unauthorized", message: "Presenter must stop the session." };
removeParticipant(session, target, request.reason);
return { ok: true, value: null };
}
function subscribe(raw: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void> {
const found = authenticate(raw, subject);
if (!found.ok) return found;
const participant = found.value.participant;
for (const message of participant.queue.splice(0)) listener(message);
participant.listeners.add(listener);
return { ok: true, value: () => participant.listeners.delete(listener) };
}
function revalidate(raw: ScreenShareCredential, subject: string): MediaSignalResult<null> {
const found = authenticate(raw, subject);
return found.ok ? { ok: true, value: null } : found;
}
function revokeSubject(subject: string): number {
let removed = 0;
for (const session of [...sessions.values()]) {
const matching = [...session.participants.values()].filter((participant) => participant.subject === subject);
const presenter = matching.find((participant) => participant.role === "presenter");
if (presenter) {
destroySession(session, stopNotice(session, "server-shutdown"));
removed += 1;
continue;
}
for (const participant of matching) {
removeParticipant(session, participant, "authorization-revoked");
removed += 1;
}
}
return removed;
}
function cleanup(): number {
let removed = 0;
for (const session of [...sessions.values()]) {
const expired = [...session.participants.values()].filter((participant) => now() >= participant.expiresAt);
const presenter = expired.find((participant) => participant.role === "presenter");
if (presenter) {
destroySession(session, stopNotice(session, "grant-expired"));
removed += 1;
continue;
}
for (const participant of expired) {
removeParticipant(session, participant, "authorization-revoked");
removed += 1;
}
}
return removed;
}
return { join, resume, signal, leave, subscribe, revalidate, revokeSubject, cleanup, sessionCount: () => sessions.size };
}
function opaque(bytes: number): string { return randomBytes(bytes).toString("base64url"); }
function hash(token: string): Buffer { return createHash("sha256").update(token).digest(); }
function tokenMatches(expected: Buffer, token: string): boolean {
if (typeof token !== "string" || token.length < 16 || token.length > 512) return false;
const actual = hash(token); return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function positive(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function count(value: number | undefined, fallback: number): number { return Math.max(1, Math.floor(positive(value, fallback))); }
function copyBinding(binding: ScreenShareBinding): ScreenShareBinding { return { ...binding }; }
function sameBinding(a: ScreenShareBinding, b: ScreenShareBinding): boolean {
return a.officeId === b.officeId && a.levelId === b.levelId && a.roomId === b.roomId && a.screenId === b.screenId;
}
+145
View File
@@ -0,0 +1,145 @@
/** Authenticated, POST-body-only office WebRTC signaling routes. */
import type { FastifyInstance, FastifyReply } from "fastify";
import { parseScreenShareClientMessage } from "../../../src/media/signalingValidation.ts";
import type {
ScreenShareBinding,
ScreenShareCreateRequest,
ScreenShareJoinRequest,
ScreenShareResumeRequest,
ScreenShareRevokeRequest,
ScreenShareSignalRequest,
ScreenShareStopRequest,
} from "../../../src/media/signalingTypes.ts";
import { bundledMediaOffice, officeHasMediaBinding, type MediaSignalFailureCode } from "../media/index.ts";
import type { Services } from "../services.ts";
const BODY_LIMIT = 32 * 1024;
const REVALIDATE_MS = 15_000;
const UNAUTHORIZED = { error: "unauthorized", message: "Media signaling requires a signed-in member." };
function status(code: MediaSignalFailureCode): number {
if (code === "unauthorized" || code === "expired") return 401;
if (code === "conflict") return 409;
if (code === "capacity" || code === "rate-limited") return 429;
return 400;
}
function fail(reply: FastifyReply, failure: { code: MediaSignalFailureCode; message: string }) {
const code = status(failure.code);
if (code === 401) reply.header("www-authenticate", "Bearer");
return reply.code(code).send({ error: failure.code, message: failure.message });
}
async function bindingAllowed(binding: ScreenShareBinding, services: Services): Promise<boolean> {
const doc = await services.offices.get(binding.officeId);
const office = doc?.floor ?? bundledMediaOffice(binding.officeId);
return office !== null && officeHasMediaBinding(office, binding);
}
function parse(body: unknown) {
const parsed = parseScreenShareClientMessage(body);
return parsed.ok ? parsed.value : null;
}
export function registerMedia(app: FastifyInstance, services: Services): void {
app.post<{ Body: unknown }>("/api/v1/media/join", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const message = parse(req.body);
if (!message || (message.type !== "screen-share-create-request" && message.type !== "screen-share-join-request")) {
return reply.code(400).send({ error: "invalid", message: "Media join request is invalid." });
}
// Authentication precedes lookup so anonymous callers cannot enumerate private packs.
if (!(await bindingAllowed(message.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const joined = services.media.join(message as ScreenShareCreateRequest | ScreenShareJoinRequest, viewer.subject);
if (!joined.ok) return fail(reply, joined);
return reply.code(201).send(joined.value);
});
app.post<{ Body: unknown }>("/api/v1/media/signal", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const message = parse(req.body);
if (!message || message.type !== "screen-share-signal-request") {
return reply.code(400).send({ error: "invalid", message: "Media signal request is invalid." });
}
if (!(await bindingAllowed(message.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const signaled = services.media.signal(message as ScreenShareSignalRequest, viewer.subject);
if (!signaled.ok) return fail(reply, signaled);
return reply.code(204).send();
});
app.post<{ Body: unknown }>("/api/v1/media/events", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const subject = viewer.subject;
const message = parse(req.body);
if (!message || message.type !== "screen-share-resume-request") {
return reply.code(400).send({ error: "invalid", message: "Media resume request is invalid." });
}
if (!(await bindingAllowed(message.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const resumed = services.media.resume(message as ScreenShareResumeRequest, subject);
if (!resumed.ok) return fail(reply, resumed);
reply.hijack();
reply.raw.writeHead(200, {
"cache-control": "private, no-store",
connection: "keep-alive",
"content-type": "text/event-stream; charset=utf-8",
"x-accel-buffering": "no",
});
const write = (value: unknown) => reply.raw.write(`data: ${JSON.stringify(value)}\n\n`);
write(resumed.value);
const activeCredential = resumed.value.grant.credential;
const subscribed = services.media.subscribe(activeCredential, subject, write);
if (!subscribed.ok) { reply.raw.end(); return; }
const interval = setInterval(() => {
// Re-resolve the caller, rather than treating the identity result from
// stream-open as permanent. This bounds issuer revocation and JWT expiry
// even while WebRTC itself continues peer-to-peer.
void services.auth.resolve(req).then((current) => {
if (!current.authenticated || current.subject !== subject) {
reply.raw.end();
return;
}
const valid = services.media.revalidate(activeCredential, subject);
if (!valid.ok) reply.raw.end();
else reply.raw.write(": keepalive\n\n");
}).catch(() => reply.raw.end());
}, REVALIDATE_MS);
interval.unref();
// The POST request body closes as soon as it is parsed; the response owns
// the fetch-streamed SSE lifetime.
reply.raw.once("close", () => { clearInterval(interval); subscribed.value(); });
});
app.post<{ Body: unknown }>("/api/v1/media/leave", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const message = parse(req.body);
if (!message || (message.type !== "screen-share-stop-request" && message.type !== "screen-share-revoke-request")) {
return reply.code(400).send({ error: "invalid", message: "Media leave request is invalid." });
}
if (!(await bindingAllowed(message.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const left = services.media.leave(message as ScreenShareStopRequest | ScreenShareRevokeRequest, viewer.subject, viewer.admin);
if (!left.ok) return fail(reply, left);
return reply.code(204).send();
});
}
+3
View File
@@ -10,6 +10,7 @@
import { createAuth, type AuthService } from "./auth/index.ts";
import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
import { createMediaSignalService, type MediaSignalService } from "./media/index.ts";
import { createOfficeStore, type OfficeStore } from "./offices/store.ts";
import { createPresenceStore, type PresenceStore } from "./presence/store.ts";
import { createRealtimeService, type RealtimeService } from "./realtime/index.ts";
@@ -23,6 +24,7 @@ export interface Services {
flights: FlightsService;
satellites: SatellitesService;
markers: MarkerStore;
media: MediaSignalService;
offices: OfficeStore;
presence: PresenceStore;
realtime: RealtimeService;
@@ -43,6 +45,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
flights: createFlightsService(config, log),
satellites: createSatellitesService(config, log),
markers: createMarkerStore(config, log),
media: createMediaSignalService(),
offices: createOfficeStore(config.offices.dir),
presence: createPresenceStore(config.presence.dir),
realtime: createRealtimeService(),
+166
View File
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { after, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { createMediaSignalService, officeHasMediaBinding } from "../media/index.ts";
import LUMBRIDGE_HQ from "../../../src/offices/lumbridge-hq.ts";
import type {
ScreenShareBinding,
ScreenShareCreateRequest,
ScreenShareJoinRequest,
ScreenShareRevokeRequest,
ScreenShareSignalRequest,
} from "../../../src/media/signalingTypes.ts";
const BINDING: ScreenShareBinding = {
officeId: "lumbridge-hq", levelId: "level-1", roomId: "lobby", screenId: "lobby-monitor",
};
const create = (sequence = 1): ScreenShareCreateRequest => ({
type: "screen-share-create-request", protocolVersion: 1, requestId: "request-create", sequence,
timestampMs: 1_000, binding: BINDING, role: "presenter",
});
const join = (sequence = 1): ScreenShareJoinRequest => ({
type: "screen-share-join-request", protocolVersion: 1, requestId: "request-join", sequence,
timestampMs: 1_000, binding: BINDING, role: "viewer", viewerOptIn: true,
});
function grants(service: ReturnType<typeof createMediaSignalService>) {
const presenter = service.join(create(), "presenter-subject");
const viewer = service.join(join(), "viewer-subject");
assert.equal(presenter.ok, true); assert.equal(viewer.ok, true);
if (!presenter.ok || !viewer.ok || presenter.value.type !== "screen-share-create-grant" || viewer.value.type !== "screen-share-join-grant") {
throw new Error("grant setup failed");
}
return { presenter: presenter.value, viewer: viewer.value };
}
describe("office media signaling service", () => {
it("issues opaque participants, snapshots peers, and enforces P2P signaling roles", () => {
const service = createMediaSignalService();
const { presenter, viewer } = grants(service);
const p = presenter.grant.credential; const v = viewer.grant.credential;
assert.notEqual(p.sessionId, p.participantId); assert.notEqual(p.participantId, p.grantToken);
assert.deepEqual(presenter.participants, []);
assert.deepEqual(viewer.participants, [{ participantId: p.participantId, role: "presenter" }]);
assert.equal(JSON.stringify(viewer).includes("subject"), false);
const received: string[] = [];
assert.equal(service.subscribe(v, "viewer-subject", (message) => received.push(message.type)).ok, true);
const offer: ScreenShareSignalRequest = {
type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_001,
binding: BINDING, credential: p, targetParticipantId: v.participantId,
signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0" },
};
assert.equal(service.signal(offer, "presenter-subject").ok, true);
assert.equal(received.includes("screen-share-signal-relay"), true);
assert.equal(service.signal({ ...offer, sequence: 3, credential: v, targetParticipantId: p.participantId }, "viewer-subject").ok, false);
assert.equal(service.signal({ ...offer, sequence: 4, credential: p }, "wrong-subject").ok, false);
});
it("rotates short-lived hashed grants and rejects stale capabilities", () => {
let now = 10_000;
const service = createMediaSignalService({ now: () => now, grantTtlMs: 100 });
const { presenter } = grants(service);
const old = presenter.grant.credential;
const resumed = service.resume({
type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-resume", sequence: 2,
timestampMs: now, binding: BINDING, credential: old, lastReceivedSequence: 0,
}, "presenter-subject");
assert.equal(resumed.ok, true);
if (!resumed.ok) return;
assert.notEqual(resumed.value.grant.credential.grantToken, old.grantToken);
assert.equal(service.revalidate(old, "presenter-subject").ok, false);
now += 101;
assert.equal(service.revalidate(resumed.value.grant.credential, "presenter-subject").ok, false);
assert.equal(service.sessionCount(), 0);
});
it("bounds the mesh and sends viewer lifecycle snapshots", () => {
const service = createMediaSignalService({ maximumParticipantsPerSession: 2 });
const { presenter, viewer } = grants(service);
assert.equal(service.join(join(2), "third-viewer").ok, false);
const snapshots: number[] = [];
service.subscribe(presenter.grant.credential, "presenter-subject", (message) => {
if (message.type === "screen-share-participants") snapshots.push(message.participants.length);
});
const leave: ScreenShareRevokeRequest = {
type: "screen-share-revoke-request", protocolVersion: 1, requestId: "request-leave", sequence: 2,
timestampMs: 1_002, binding: BINDING, credential: viewer.grant.credential,
scope: "participant", targetParticipantId: viewer.grant.credential.participantId, reason: "viewer-left",
};
assert.equal(service.leave(leave, "viewer-subject", false).ok, true);
assert.equal(snapshots.at(-1), 0);
});
it("validates the exact authored level, room and screen prop", () => {
assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, BINDING), true);
assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, { ...BINDING, roomId: "open-floor" }), false);
assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, { ...BINDING, screenId: "not-authored" }), false);
});
});
const SECRET = "media-route-test-secret-that-is-long-enough";
function bearer(subject: string) {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ sub: subject, exp: Math.floor(Date.now() / 1000) + 600 })}`;
return { authorization: `Bearer ${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}` };
}
describe("office media signaling routes", () => {
it("authenticates before exact lookup and keeps capabilities in POST bodies", async () => {
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET }); config.logLevel = "silent";
const app = buildApp(config); after(() => app.close());
const anonymous = await app.inject({ method: "POST", url: "/api/v1/media/join", payload: create() });
assert.equal(anonymous.statusCode, 401);
const locator = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("presenter"), payload: { ...create(), sourceUrl: "https://private.invalid/stream" } });
assert.equal(locator.statusCode, 400);
const wrongRoom = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("viewer"), payload: { ...join(), binding: { ...BINDING, roomId: "not-the-room" } } });
assert.equal(wrongRoom.statusCode, 404);
const presenter = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("presenter"), payload: create() });
const viewer = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("viewer"), payload: join() });
assert.equal(presenter.statusCode, 201); assert.equal(viewer.statusCode, 201);
assert.equal(presenter.headers["cache-control"], "private, no-store"); assert.equal(presenter.headers.location, undefined);
const p = presenter.json<{ grant: { credential: ScreenShareSignalRequest["credential"] } }>();
const v = viewer.json<{ grant: { credential: ScreenShareSignalRequest["credential"] } }>();
const sent = await app.inject({ method: "POST", url: "/api/v1/media/signal", headers: bearer("presenter"), payload: {
type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_002,
binding: BINDING, credential: p.grant.credential, targetParticipantId: v.grant.credential.participantId,
signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0" },
} });
assert.equal(sent.statusCode, 204);
const stolen = await app.inject({ method: "POST", url: "/api/v1/media/signal", headers: bearer("viewer"), payload: {
type: "screen-share-signal-request", protocolVersion: 1, sequence: 3, timestampMs: 1_003,
binding: BINDING, credential: p.grant.credential, targetParticipantId: v.grant.credential.participantId,
signal: { kind: "ice-complete" },
} });
assert.equal(stolen.statusCode, 401);
});
it("opens SSE with a POST-body resume grant and rotates the capability", async () => {
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET }); config.logLevel = "silent";
const app = buildApp(config); after(() => app.close());
const origin = await app.listen({ host: "127.0.0.1", port: 0 });
const created = await fetch(`${origin}/api/v1/media/join`, {
method: "POST", headers: { ...bearer("presenter"), "content-type": "application/json" }, body: JSON.stringify(create()),
});
const body = await created.json() as { grant: { credential: ScreenShareSignalRequest["credential"] } };
const controller = new AbortController();
const streamed = await fetch(`${origin}/api/v1/media/events`, {
method: "POST", signal: controller.signal,
headers: { ...bearer("presenter"), "content-type": "application/json" },
body: JSON.stringify({
type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-events", sequence: 2,
timestampMs: 1_003, binding: BINDING, credential: body.grant.credential, lastReceivedSequence: 0,
}),
});
assert.equal(streamed.status, 200);
assert.match(streamed.headers.get("content-type") ?? "", /^text\/event-stream/);
const first = await streamed.body?.getReader().read();
const text = new TextDecoder().decode(first?.value);
assert.match(text, /screen-share-resume-grant/);
assert.equal(text.includes(body.grant.credential.grantToken), false);
assert.equal(streamed.url.includes("grantToken"), false);
controller.abort();
});
});
+20
View File
@@ -23,3 +23,23 @@ export {
type OfficeScreenPanelOptions,
type OfficeScreenPanelState,
} from "./officeScreenPanel.ts";
export {
createRemoteOfficeMedia,
type PresenterStart,
type RemoteMediaConsent,
type RemoteMediaEndpoints,
type RemoteMediaRole,
type RemoteMediaState,
type RemoteMediaStatus,
type RemoteMediaTimers,
type RemoteOfficeMedia,
type RemoteOfficeMediaOptions,
type ViewerStart,
} from "./remoteMedia.ts";
export * from "./signalingTypes.ts";
export {
advanceScreenShareStreamCursor,
isScreenShareGrantActive,
parseScreenShareClientMessage,
parseScreenShareServerMessage,
} from "./signalingValidation.ts";
+591
View File
@@ -0,0 +1,591 @@
/** Privacy-gated WebRTC transport for the shared office signaling contract. */
import {
advanceScreenShareStreamCursor,
isScreenShareGrantActive,
parseScreenShareServerMessage,
} from "./signalingValidation.ts";
import {
SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
type ScreenShareAccessGrant,
type ScreenShareBinding,
type ScreenShareClientMessage,
type ScreenShareCredential,
type ScreenShareParticipant,
type ScreenSharePeerMessage,
type ScreenShareResumeGrant,
type ScreenShareRole,
type ScreenShareServerMessage,
type ScreenShareSignalPayload,
type ScreenShareStreamCursor,
} from "./signalingTypes.ts";
import type { MediaAuthorizationDecision } from "./types.ts";
export type RemoteMediaRole = ScreenShareRole;
export type RemoteMediaStatus = "idle" | "connecting" | "live" | "reconnecting" | "stopped" | "revoked" | "disposed";
export interface RemoteMediaConsent { authorized: boolean; optedIn: boolean }
export interface RemoteMediaState {
role: RemoteMediaRole;
status: RemoteMediaStatus;
binding: ScreenShareBinding;
sessionId: string | null;
participantCount: number;
reconnectAttempt: number;
hasRemoteVideo: boolean;
}
export interface RemoteMediaEndpoints { join: string; signal: string; events: string; leave: string }
export interface RemoteMediaTimers {
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
}
export interface RemoteOfficeMediaOptions {
role: RemoteMediaRole;
binding: ScreenShareBinding;
/** Preferred: the app's authenticated fetch wrapper. Tokens remain in POST bodies. */
authenticatedFetch?: typeof globalThis.fetch;
fetch?: typeof globalThis.fetch;
peerConnectionFactory?: (configuration?: RTCConfiguration) => RTCPeerConnection;
peerConnectionConfiguration?: RTCConfiguration;
endpoints?: Partial<RemoteMediaEndpoints>;
timers?: RemoteMediaTimers;
now?: () => number;
reconnectBaseMs?: number;
reconnectMaximumMs?: number;
onStateChange?(state: RemoteMediaState): void;
onError?(error: Error): void;
}
export interface PresenterStart {
consent: RemoteMediaConsent;
/** Caller-owned. Exactly one live video track is published and never stopped here. */
stream: MediaStream;
video: HTMLVideoElement;
}
export interface ViewerStart {
/** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */
decision: MediaAuthorizationDecision;
video: HTMLVideoElement;
}
export interface RemoteOfficeMedia {
startPresenter(input: PresenterStart): Promise<void>;
startViewer(input: ViewerStart): Promise<void>;
state(): RemoteMediaState;
stop(): Promise<void>;
revoke(): Promise<void>;
dispose(): Promise<void>;
}
interface PeerSlot {
readonly id: string;
readonly connection: RTCPeerConnection;
readonly pendingIce: (RTCIceCandidateInit | null)[];
}
const ENDPOINTS: RemoteMediaEndpoints = {
join: "/api/v1/media/join",
signal: "/api/v1/media/signal",
events: "/api/v1/media/events",
leave: "/api/v1/media/leave",
};
const TIMERS: RemoteMediaTimers = {
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
clearTimeout: (handle) => globalThis.clearTimeout(handle),
};
function copyBinding(value: ScreenShareBinding): ScreenShareBinding {
for (const key of ["officeId", "levelId", "screenId"] as const) {
if (typeof value[key] !== "string" || value[key].length < 1 || value[key].length > 128) {
throw new TypeError(`remote media: invalid ${key}`);
}
}
if (value.roomId !== null && (typeof value.roomId !== "string" || value.roomId.length < 1 || value.roomId.length > 128)) {
throw new TypeError("remote media: invalid roomId");
}
return { ...value };
}
function sameBinding(a: ScreenShareBinding, b: ScreenShareBinding): boolean {
return a.officeId === b.officeId && a.levelId === b.levelId && a.roomId === b.roomId && a.screenId === b.screenId;
}
function asError(reason: unknown, fallback: string): Error { return reason instanceof Error ? reason : new Error(fallback); }
function requestId(number: number): string { return `tera-media-${number}`; }
function sdp(value: RTCSessionDescriptionInit): ScreenShareSignalPayload {
if ((value.type !== "offer" && value.type !== "answer") || typeof value.sdp !== "string") {
throw new Error("remote media: local SDP is invalid");
}
return { kind: "sdp", descriptionType: value.type, sdp: value.sdp };
}
function remoteDescription(signal: Extract<ScreenShareSignalPayload, { kind: "sdp" }>): RTCSessionDescriptionInit {
return { type: signal.descriptionType, sdp: signal.sdp };
}
/**
* Capture-free and storage-free by construction. The adapter never invokes a
* media acquisition API, calls `play`, records, uploads media, or exposes the
* server grant in state/events. It owns peer connections and received tracks;
* the presenter's input stream, video, and track remain caller-owned.
*/
export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): RemoteOfficeMedia {
if (options.authenticatedFetch && options.fetch) throw new TypeError("remote media: provide one fetch adapter");
const fetcher = options.authenticatedFetch ?? options.fetch ?? globalThis.fetch;
if (typeof fetcher !== "function") throw new TypeError("remote media: fetch is required");
const makePeer = options.peerConnectionFactory ?? ((configuration) => new RTCPeerConnection(configuration));
const binding = copyBinding(options.binding);
const endpoints = { ...ENDPOINTS, ...options.endpoints };
const timers = options.timers ?? TIMERS;
const now = options.now ?? Date.now;
const reconnectBase = options.reconnectBaseMs ?? 500;
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000;
if (!(reconnectBase > 0) || !Number.isFinite(reconnectBase) || reconnectMaximum < reconnectBase) {
throw new RangeError("remote media: invalid reconnect bounds");
}
let status: RemoteMediaStatus = "idle";
let grant: ScreenShareAccessGrant | null = null;
let clientSequence = 0;
let counter = 0;
let cursor: ScreenShareStreamCursor | null = null;
let participants: readonly ScreenShareParticipant[] = [];
let presenter: PresenterStart | null = null;
let viewer: ViewerStart | null = null;
let receiverStream: MediaStream | null = null;
let eventAbort: AbortController | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let renewalTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempt = 0;
let generation = 0;
const peers = new Map<string, PeerSlot>();
function state(): RemoteMediaState {
return {
role: options.role,
status,
binding: { ...binding },
sessionId: grant?.credential.sessionId ?? null,
participantCount: peers.size,
reconnectAttempt,
hasRemoteVideo: receiverStream !== null,
};
}
function changed(): void { options.onStateChange?.(state()); }
function setStatus(next: RemoteMediaStatus): void { status = next; changed(); }
function envelope(): { protocolVersion: 1; sequence: number; timestampMs: number } {
const result = { protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION, sequence: clientSequence, timestampMs: now() };
clientSequence += 1;
return result;
}
async function post(endpoint: string, message: ScreenShareClientMessage, signal?: AbortSignal): Promise<Response> {
return fetcher(endpoint, {
method: "POST",
credentials: "same-origin",
cache: "no-store",
redirect: "error",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(message),
signal,
});
}
async function parsedResponse(response: Response): Promise<ScreenShareServerMessage> {
if (!response.ok) throw new Error(`remote media request failed (${response.status})`);
const parsed = parseScreenShareServerMessage(await response.json() as unknown);
if (!parsed.ok) throw new Error(parsed.error);
return parsed.value;
}
function releaseReceiver(): void {
if (receiverStream) for (const track of receiverStream.getTracks()) track.stop();
if (viewer?.video.srcObject === receiverStream) viewer.video.srcObject = null;
receiverStream = null;
}
function closePeer(id: string): void {
const slot = peers.get(id);
if (!slot) return;
slot.connection.onicecandidate = null;
slot.connection.ontrack = null;
slot.connection.onconnectionstatechange = null;
slot.connection.close();
peers.delete(id);
}
function closeTransport(): void {
generation += 1;
eventAbort?.abort();
eventAbort = null;
if (reconnectTimer !== null) timers.clearTimeout(reconnectTimer);
reconnectTimer = null;
if (renewalTimer !== null) timers.clearTimeout(renewalTimer);
renewalTimer = null;
for (const id of [...peers.keys()]) closePeer(id);
releaseReceiver();
}
function resetPeerMesh(): void {
for (const id of [...peers.keys()]) closePeer(id);
releaseReceiver();
}
async function sendSignal(targetParticipantId: string, signal: ScreenShareSignalPayload): Promise<void> {
if (!grant) throw new Error("remote media: signaling grant is missing");
const request: ScreenShareClientMessage = {
type: "screen-share-signal-request",
...envelope(),
binding,
credential: grant.credential,
targetParticipantId,
signal,
};
const response = await post(endpoints.signal, request);
if (!response.ok) throw new Error(`remote media signal failed (${response.status})`);
}
function configurePeer(participant: ScreenShareParticipant): PeerSlot {
const prior = peers.get(participant.participantId);
if (prior) return prior;
const activeGeneration = generation;
const connection = makePeer(options.peerConnectionConfiguration);
const slot: PeerSlot = { id: participant.participantId, connection, pendingIce: [] };
peers.set(slot.id, slot);
connection.onicecandidate = (event) => {
if (generation !== activeGeneration) return;
const signal: ScreenShareSignalPayload = event.candidate
? {
kind: "ice-candidate",
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
usernameFragment: event.candidate.usernameFragment,
}
: { kind: "ice-complete" };
void sendSignal(slot.id, signal).catch((reason) => options.onError?.(asError(reason, "remote media ICE failed")));
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === "connected") { reconnectAttempt = 0; setStatus("live"); }
else if (connection.connectionState === "failed" || connection.connectionState === "disconnected") scheduleReconnect();
};
if (options.role === "presenter") {
const track = presenter?.stream.getVideoTracks()[0];
if (!track || track.readyState !== "live") throw new Error("remote media: presenter track ended");
connection.addTrack(track, presenter!.stream);
} else {
connection.addTransceiver("video", { direction: "recvonly" });
connection.ontrack = (event) => {
if (generation !== activeGeneration || status === "revoked" || status === "disposed") {
event.track.stop();
return;
}
releaseReceiver();
receiverStream = event.streams[0] ?? new MediaStream([event.track]);
viewer!.video.srcObject = receiverStream;
changed();
};
}
changed();
return slot;
}
async function offer(participant: ScreenShareParticipant): Promise<void> {
const connection = configurePeer(participant).connection;
const description = await connection.createOffer();
await connection.setLocalDescription(description);
await sendSignal(participant.participantId, sdp(connection.localDescription ?? description));
}
async function reconcile(next: readonly ScreenShareParticipant[]): Promise<void> {
if (!grant) return;
const own = grant.credential.participantId;
const allowed = next.filter((item) => item.participantId !== own && item.role !== options.role);
const ids = new Set(allowed.map((item) => item.participantId));
for (const id of [...peers.keys()]) if (!ids.has(id)) closePeer(id);
participants = allowed;
if (options.role === "presenter") {
for (const participant of allowed) if (!peers.has(participant.participantId)) await offer(participant);
} else {
const presenterPeer = allowed.find((item) => item.role === "presenter");
if (presenterPeer) configurePeer(presenterPeer);
}
changed();
}
async function applyPeerMessage(message: ScreenSharePeerMessage): Promise<void> {
if (!grant || message.sessionId !== grant.credential.sessionId || !sameBinding(message.binding, binding)) return;
const advanced = advanceScreenShareStreamCursor(cursor, message);
if (!advanced.ok) return;
cursor = advanced.value;
if (message.type === "screen-share-stopped") {
closeTransport();
setStatus("stopped");
return;
}
if (message.type === "screen-share-revoked") {
if (message.scope === "session" || message.targetParticipantId === grant.credential.participantId) {
closeTransport();
grant = null;
setStatus("revoked");
} else if (message.targetParticipantId) closePeer(message.targetParticipantId);
return;
}
if (message.type === "screen-share-participants") {
await reconcile(message.participants);
return;
}
if (message.targetParticipantId !== grant.credential.participantId) return;
const from = participants.find((item) => item.participantId === message.fromParticipantId);
if (!from) return;
const connection = configurePeer(from).connection;
if (message.signal.kind === "ice-complete") {
if (connection.remoteDescription) await connection.addIceCandidate(null);
else peers.get(from.participantId)?.pendingIce.push(null);
return;
}
if (message.signal.kind === "ice-candidate") {
const incoming: RTCIceCandidateInit = {
candidate: message.signal.candidate,
sdpMid: message.signal.sdpMid,
sdpMLineIndex: message.signal.sdpMLineIndex,
usernameFragment: message.signal.usernameFragment ?? undefined,
};
if (connection.remoteDescription) await connection.addIceCandidate(incoming);
else peers.get(from.participantId)?.pendingIce.push(incoming);
return;
}
await connection.setRemoteDescription(remoteDescription(message.signal));
const slot = peers.get(from.participantId);
if (slot) {
for (const incoming of slot.pendingIce.splice(0)) await connection.addIceCandidate(incoming);
}
if (message.signal.descriptionType === "offer") {
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
await sendSignal(from.participantId, sdp(connection.localDescription ?? answer));
}
}
async function readEvents(response: Response, activeGeneration: number, expectedRequestId: string): Promise<void> {
if (!response.ok || !response.body) throw new Error(`remote media events failed (${response.status})`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let first = true;
try {
while (generation === activeGeneration) {
const chunk = await reader.read();
if (generation !== activeGeneration || chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r/g, "");
let boundary = buffer.indexOf("\n\n");
while (boundary >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf("\n\n");
const data = block.split("\n").filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, "")).join("\n");
if (!data) continue;
let raw: unknown;
try { raw = JSON.parse(data); } catch { continue; }
const parsed = parseScreenShareServerMessage(raw);
if (!parsed.ok) continue;
if (first) {
first = false;
if (parsed.value.type !== "screen-share-resume-grant" ||
parsed.value.requestId !== expectedRequestId || !(await acceptGrant(parsed.value))) {
throw new Error("remote media: event stream did not start with a valid resume grant");
}
} else if (parsed.value.type === "screen-share-signal-relay" ||
parsed.value.type === "screen-share-participants" ||
parsed.value.type === "screen-share-stopped" ||
parsed.value.type === "screen-share-revoked") {
await applyPeerMessage(parsed.value);
}
}
}
if (generation === activeGeneration && first) throw new Error("remote media: event stream ended before resume grant");
} finally { reader.releaseLock(); }
}
async function acceptGrant(message: ScreenShareResumeGrant): Promise<boolean> {
const current = grant?.credential;
if (!current || message.grant.credential.sessionId !== current.sessionId ||
message.grant.credential.participantId !== current.participantId ||
!sameBinding(message.binding, binding) || message.grant.credential.role !== options.role ||
!isScreenShareGrantActive(message.grant, now())) return false;
if (grant && (message.grant.credential.sessionId !== grant.credential.sessionId ||
message.grant.credential.participantId !== grant.credential.participantId)) return false;
grant = message.grant;
clientSequence = message.nextClientSequence;
cursor = { sessionId: message.grant.credential.sessionId, sequence: message.sequence, timestampMs: message.timestampMs };
if (!message.continuous) resetPeerMesh();
await reconcile(message.participants);
scheduleRenewal();
setStatus("connecting");
return true;
}
function scheduleRenewal(): void {
if (!grant || status === "revoked" || status === "stopped" || status === "disposed") return;
if (renewalTimer !== null) timers.clearTimeout(renewalTimer);
const delay = Math.max(1_000, grant.expiresAtMs - now() - 15_000);
renewalTimer = timers.setTimeout(() => {
renewalTimer = null;
void startEvents().catch((reason) => {
options.onError?.(asError(reason, "remote media lease renewal failed"));
scheduleReconnect();
});
}, delay);
}
function scheduleReconnect(): void {
if (!grant || status === "revoked" || status === "stopped" || status === "disposed" || reconnectTimer !== null) return;
// A failed RTCPeerConnection is not recoverable by renewing the signaling
// lease alone. Tear down the peer mesh and let the resume snapshot rebuild
// it; presenter input tracks remain untouched and reusable.
generation += 1;
eventAbort?.abort();
eventAbort = null;
if (renewalTimer !== null) timers.clearTimeout(renewalTimer);
renewalTimer = null;
resetPeerMesh();
setStatus("reconnecting");
const delay = Math.min(reconnectMaximum, reconnectBase * 2 ** reconnectAttempt);
reconnectAttempt += 1;
reconnectTimer = timers.setTimeout(() => {
reconnectTimer = null;
void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media reconnect failed")); scheduleReconnect(); });
}, delay);
}
async function startEvents(): Promise<void> {
if (!grant) throw new Error("remote media: grant is missing");
const activeGeneration = generation;
eventAbort?.abort();
const abort = new AbortController();
eventAbort = abort;
const request: ScreenShareClientMessage = {
type: "screen-share-resume-request",
...envelope(),
requestId: requestId(++counter),
binding,
credential: grant.credential,
lastReceivedSequence: cursor?.sequence ?? 0,
};
try {
const response = await post(endpoints.events, request, abort.signal);
await readEvents(response, activeGeneration, request.requestId);
if (generation === activeGeneration) scheduleReconnect();
} catch (reason) {
if (generation !== activeGeneration || abort.signal.aborted) return;
throw reason;
}
}
async function join(role: RemoteMediaRole, activeGeneration: number): Promise<void> {
const id = requestId(++counter);
const message: ScreenShareClientMessage = role === "presenter"
? { type: "screen-share-create-request", ...envelope(), requestId: id, binding, role }
: { type: "screen-share-join-request", ...envelope(), requestId: id, binding, role, viewerOptIn: true };
const response = await parsedResponse(await post(endpoints.join, message));
const expected = role === "presenter" ? "screen-share-create-grant" : "screen-share-join-grant";
if (response.type !== expected || response.requestId !== id || !sameBinding(response.binding, binding) ||
response.grant.credential.role !== role || !isScreenShareGrantActive(response.grant, now())) {
throw new Error("remote media: join grant is invalid");
}
if (generation !== activeGeneration || status === "disposed" || status === "revoked" || status === "stopped") {
throw new Error("remote media: join was superseded");
}
grant = response.grant;
clientSequence = response.nextClientSequence;
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs };
await reconcile(response.participants);
scheduleRenewal();
void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media events failed")); scheduleReconnect(); });
}
async function startPresenter(input: PresenterStart): Promise<void> {
if (options.role !== "presenter") throw new Error("remote media: adapter is not a presenter");
if (status === "disposed" || status === "revoked") throw new Error("remote media: session is terminal");
if (!input.consent.authorized || !input.consent.optedIn) throw new Error("remote media: authorization and explicit opt-in are required");
const tracks = input.stream.getVideoTracks().filter((track) => track.readyState === "live");
if (tracks.length !== 1) throw new Error("remote media: presenter requires exactly one live screen track");
input.video.autoplay = false;
input.video.muted = true;
closeTransport();
grant = null;
participants = [];
presenter = input;
viewer = null;
setStatus("connecting");
try {
await join("presenter", generation);
} catch (reason) {
if (status === "connecting") {
closeTransport();
grant = null;
presenter = null;
setStatus("idle");
}
throw reason;
}
}
async function startViewer(input: ViewerStart): Promise<void> {
if (options.role !== "viewer") throw new Error("remote media: adapter is not a viewer");
if (status === "disposed" || status === "revoked") throw new Error("remote media: session is terminal");
if (!input.decision.authorized || !input.decision.optedIn || !input.decision.canView ||
input.decision.surface.screenId !== binding.screenId || input.decision.surface.officeId !== binding.officeId ||
input.decision.surface.source?.kind !== "live-stream") {
throw new Error("remote media: a ready live-stream authorization decision is required");
}
input.video.autoplay = false;
input.video.muted = true;
closeTransport();
grant = null;
participants = [];
viewer = input;
presenter = null;
setStatus("connecting");
try {
await join("viewer", generation);
} catch (reason) {
if (status === "connecting") {
closeTransport();
grant = null;
viewer = null;
setStatus("idle");
}
throw reason;
}
}
async function leave(): Promise<void> {
const credential = grant?.credential;
closeTransport();
participants = [];
grant = null;
if (!credential) return;
// A presenter ending the whole share is always a stop. `revoke` is a
// policy word locally, not permission to claim a moderator action on wire.
const message: ScreenShareClientMessage = options.role === "presenter"
? {
type: "screen-share-stop-request", ...envelope(), requestId: requestId(++counter), binding,
credential: credential as ScreenShareCredential<"presenter">, reason: "presenter-stopped",
}
: {
type: "screen-share-revoke-request", ...envelope(), requestId: requestId(++counter), binding,
credential, scope: "participant",
targetParticipantId: credential.participantId,
reason: "viewer-left",
};
const response = await post(endpoints.leave, message);
if (!response.ok) throw new Error(`remote media leave failed (${response.status})`);
}
async function stop(): Promise<void> {
if (status === "disposed" || status === "stopped") return;
try { await leave(); } finally { presenter = null; viewer = null; setStatus("stopped"); }
}
async function revoke(): Promise<void> {
if (status === "disposed" || status === "revoked") return;
try { await leave(); } finally { presenter = null; viewer = null; setStatus("revoked"); }
}
async function dispose(): Promise<void> {
if (status === "disposed") return;
try { await leave(); } finally { presenter = null; viewer = null; status = "disposed"; changed(); }
}
return { startPresenter, startViewer, state, stop, revoke, dispose };
}
+251
View File
@@ -0,0 +1,251 @@
/**
* JSON-safe signaling contracts for an eventual server-authoritative office
* screen-share service. These types intentionally contain no browser or WebRTC
* runtime objects and no MediaSurfaceSource locator.
*/
export const SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION = 1 as const;
/** One presenter plus at most seven viewers in the initial bounded P2P mesh. */
export const MAX_SCREEN_SHARE_PARTICIPANTS = 8 as const;
export type ScreenShareRole = "presenter" | "viewer";
export type ScreenShareSequence = number;
export type ScreenShareTimestamp = number;
/** The authored office surface to which a signaling session is bound. */
export interface ScreenShareBinding {
officeId: string;
levelId: string;
roomId: string | null;
screenId: string;
}
/**
* Private bearer material. It may cross the client/server boundary but must
* never be copied into a ScreenSharePeerMessage.
*/
export interface ScreenShareCredential<R extends ScreenShareRole = ScreenShareRole> {
sessionId: string;
participantId: string;
role: R;
grantToken: string;
}
export interface ScreenShareAccessGrant<R extends ScreenShareRole = ScreenShareRole> {
credential: ScreenShareCredential<R>;
issuedAtMs: ScreenShareTimestamp;
expiresAtMs: ScreenShareTimestamp;
}
/** Server-issued, session-scoped identity; never an account/profile identifier. */
export interface ScreenShareParticipant {
participantId: string;
role: ScreenShareRole;
}
export interface ScreenShareSdpSignal {
kind: "sdp";
descriptionType: "offer" | "answer";
sdp: string;
}
export interface ScreenShareIceCandidateSignal {
kind: "ice-candidate";
candidate: string;
sdpMid: string | null;
sdpMLineIndex: number | null;
usernameFragment: string | null;
}
export interface ScreenShareIceCompleteSignal {
kind: "ice-complete";
}
export type ScreenShareSignalPayload =
| ScreenShareSdpSignal
| ScreenShareIceCandidateSignal
| ScreenShareIceCompleteSignal;
interface ScreenShareEnvelope {
protocolVersion: typeof SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION;
sequence: ScreenShareSequence;
timestampMs: ScreenShareTimestamp;
}
interface ScreenShareRequestedEnvelope extends ScreenShareEnvelope {
requestId: string;
}
export interface ScreenShareCreateRequest extends ScreenShareRequestedEnvelope {
type: "screen-share-create-request";
binding: ScreenShareBinding;
role: "presenter";
}
export interface ScreenShareJoinRequest extends ScreenShareRequestedEnvelope {
type: "screen-share-join-request";
binding: ScreenShareBinding;
role: "viewer";
/** A literal, affirmative action; omission and false are both invalid. */
viewerOptIn: true;
}
export interface ScreenShareResumeRequest extends ScreenShareRequestedEnvelope {
type: "screen-share-resume-request";
binding: ScreenShareBinding;
credential: ScreenShareCredential;
lastReceivedSequence: ScreenShareSequence;
}
export interface ScreenShareSignalRequest extends ScreenShareEnvelope {
type: "screen-share-signal-request";
binding: ScreenShareBinding;
credential: ScreenShareCredential;
targetParticipantId: string;
signal: ScreenShareSignalPayload;
}
export type ScreenShareClientStopReason = "presenter-stopped" | "capture-ended";
export interface ScreenShareStopRequest extends ScreenShareRequestedEnvelope {
type: "screen-share-stop-request";
binding: ScreenShareBinding;
credential: ScreenShareCredential<"presenter">;
reason: ScreenShareClientStopReason;
}
export type ScreenShareRevokeScope = "session" | "participant";
export type ScreenShareRevokeRequestReason =
| "viewer-left"
| "presenter-removed-viewer"
| "moderator-action";
export interface ScreenShareRevokeRequest extends ScreenShareRequestedEnvelope {
type: "screen-share-revoke-request";
binding: ScreenShareBinding;
credential: ScreenShareCredential;
scope: ScreenShareRevokeScope;
targetParticipantId: string | null;
reason: ScreenShareRevokeRequestReason;
}
interface ScreenShareGrantEnvelope<R extends ScreenShareRole> extends ScreenShareRequestedEnvelope {
binding: ScreenShareBinding;
grant: ScreenShareAccessGrant<R>;
nextClientSequence: ScreenShareSequence;
/** Authorized peers excluding the grant recipient. */
participants: readonly ScreenShareParticipant[];
}
export interface ScreenShareCreateGrant extends ScreenShareGrantEnvelope<"presenter"> {
type: "screen-share-create-grant";
}
export interface ScreenShareJoinGrant extends ScreenShareGrantEnvelope<"viewer"> {
type: "screen-share-join-grant";
}
export interface ScreenShareResumeGrant extends ScreenShareGrantEnvelope<ScreenShareRole> {
type: "screen-share-resume-grant";
/** False requires a fresh negotiation; no missed relays are implied. */
continuous: boolean;
}
/**
* Current authorized peers. Clients reconcile their P2P mesh to this complete
* list and close any connection absent from it.
*/
export interface ScreenShareParticipants extends ScreenShareEnvelope {
type: "screen-share-participants";
sessionId: string;
binding: ScreenShareBinding;
participants: readonly ScreenShareParticipant[];
leaseExpiresAtMs: ScreenShareTimestamp;
}
/** Credential-free message safe to deliver to one explicitly opted-in peer. */
export interface ScreenShareSignalRelay extends ScreenShareEnvelope {
type: "screen-share-signal-relay";
sessionId: string;
binding: ScreenShareBinding;
fromParticipantId: string;
targetParticipantId: string;
signal: ScreenShareSignalPayload;
}
export type ScreenShareStoppedReason =
| ScreenShareClientStopReason
| "grant-expired"
| "server-shutdown";
/** Credential-free terminal notice safe to deliver to session peers. */
export interface ScreenShareStopped extends ScreenShareEnvelope {
type: "screen-share-stopped";
sessionId: string;
binding: ScreenShareBinding;
reason: ScreenShareStoppedReason;
}
export type ScreenShareRevocationReason =
| "viewer-left"
| "presenter-removed-viewer"
| "moderator-action"
| "authorization-revoked"
| "membership-revoked"
| "protocol-violation"
| "session-replaced";
/** Credential-free revocation notice; participant ids are session-opaque. */
export interface ScreenShareRevoked extends ScreenShareEnvelope {
type: "screen-share-revoked";
sessionId: string;
binding: ScreenShareBinding;
scope: ScreenShareRevokeScope;
targetParticipantId: string | null;
reason: ScreenShareRevocationReason;
reconnectAllowed: boolean;
}
export type ScreenShareClientMessage =
| ScreenShareCreateRequest
| ScreenShareJoinRequest
| ScreenShareResumeRequest
| ScreenShareSignalRequest
| ScreenShareStopRequest
| ScreenShareRevokeRequest;
export type ScreenShareServerMessage =
| ScreenShareCreateGrant
| ScreenShareJoinGrant
| ScreenShareResumeGrant
| ScreenShareParticipants
| ScreenShareSignalRelay
| ScreenShareStopped
| ScreenShareRevoked;
/** The only server messages permitted on a peer fan-out channel. */
export type ScreenSharePeerMessage =
| ScreenShareParticipants
| ScreenShareSignalRelay
| ScreenShareStopped
| ScreenShareRevoked;
export type ScreenShareSessionMessage =
| ScreenShareResumeRequest
| ScreenShareSignalRequest
| ScreenShareStopRequest
| ScreenShareRevokeRequest
| ScreenShareParticipants
| ScreenShareSignalRelay
| ScreenShareStopped
| ScreenShareRevoked;
export interface ScreenShareStreamCursor {
sessionId: string;
sequence: ScreenShareSequence;
timestampMs: ScreenShareTimestamp;
}
export type ScreenShareValidationResult<T> =
| { ok: true; value: T }
| { ok: false; error: string };
+212
View File
@@ -0,0 +1,212 @@
/** Strict runtime boundary for the JSON-only office screen signaling protocol. */
import {
MAX_SCREEN_SHARE_PARTICIPANTS,
SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
type ScreenShareAccessGrant,
type ScreenShareBinding,
type ScreenShareClientMessage,
type ScreenShareCredential,
type ScreenShareParticipant,
type ScreenSharePeerMessage,
type ScreenShareServerMessage,
type ScreenShareSignalPayload,
type ScreenShareStreamCursor,
type ScreenShareValidationResult,
} from "./signalingTypes.ts";
const MAX_ID = 256;
const MAX_TOKEN = 512;
const MAX_SDP = 24 * 1024;
const MAX_CANDIDATE = 4 * 1024;
type RecordValue = Record<string, unknown>;
function plain(value: unknown): value is RecordValue {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype;
}
function exact(value: unknown, keys: readonly string[]): value is RecordValue {
if (!plain(value)) return false;
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function id(value: unknown, maximum = MAX_ID): value is string {
return typeof value === "string" && value.length > 0 && value.length <= maximum;
}
function sequence(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function timestamp(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function envelope(value: RecordValue): boolean {
return value.protocolVersion === SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION &&
sequence(value.sequence) && timestamp(value.timestampMs);
}
function binding(value: unknown): value is ScreenShareBinding {
return exact(value, ["officeId", "levelId", "roomId", "screenId"]) &&
id(value.officeId, 128) && id(value.levelId, 128) &&
(value.roomId === null || id(value.roomId, 128)) && id(value.screenId, 128);
}
function credential(value: unknown): value is ScreenShareCredential {
return exact(value, ["sessionId", "participantId", "role", "grantToken"]) &&
id(value.sessionId) && id(value.participantId) &&
(value.role === "presenter" || value.role === "viewer") && id(value.grantToken, MAX_TOKEN);
}
function participant(value: unknown): value is ScreenShareParticipant {
return exact(value, ["participantId", "role"]) && id(value.participantId) &&
(value.role === "presenter" || value.role === "viewer");
}
function participants(value: unknown): value is readonly ScreenShareParticipant[] {
if (!Array.isArray(value) || value.length > MAX_SCREEN_SHARE_PARTICIPANTS - 1 || !value.every(participant)) return false;
const ids = value.map((item) => item.participantId);
return new Set(ids).size === ids.length && value.filter((item) => item.role === "presenter").length <= 1;
}
function accessGrant(value: unknown): value is ScreenShareAccessGrant {
return exact(value, ["credential", "issuedAtMs", "expiresAtMs"]) && credential(value.credential) &&
timestamp(value.issuedAtMs) && timestamp(value.expiresAtMs) && value.expiresAtMs > value.issuedAtMs;
}
function signal(value: unknown): value is ScreenShareSignalPayload {
if (!plain(value)) return false;
if (value.kind === "sdp") {
return exact(value, ["kind", "descriptionType", "sdp"]) &&
(value.descriptionType === "offer" || value.descriptionType === "answer") &&
id(value.sdp, MAX_SDP);
}
if (value.kind === "ice-candidate") {
return exact(value, ["kind", "candidate", "sdpMid", "sdpMLineIndex", "usernameFragment"]) &&
typeof value.candidate === "string" && value.candidate.length <= MAX_CANDIDATE &&
(value.sdpMid === null || id(value.sdpMid, 256)) &&
(value.sdpMLineIndex === null || (Number.isInteger(value.sdpMLineIndex) && (value.sdpMLineIndex as number) >= 0 && (value.sdpMLineIndex as number) <= 65_535)) &&
(value.usernameFragment === null || id(value.usernameFragment, 256));
}
return value.kind === "ice-complete" && exact(value, ["kind"]);
}
function requested(value: RecordValue): boolean {
return envelope(value) && id(value.requestId);
}
export function parseScreenShareClientMessage(value: unknown): ScreenShareValidationResult<ScreenShareClientMessage> {
if (!plain(value) || !envelope(value) || typeof value.type !== "string") return failure("invalid client envelope");
switch (value.type) {
case "screen-share-create-request":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "requestId", "binding", "role"]) &&
requested(value) && binding(value.binding) && value.role === "presenter") return success(value as unknown as ScreenShareClientMessage);
break;
case "screen-share-join-request":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "requestId", "binding", "role", "viewerOptIn"]) &&
requested(value) && binding(value.binding) && value.role === "viewer" && value.viewerOptIn === true) return success(value as unknown as ScreenShareClientMessage);
break;
case "screen-share-resume-request":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "requestId", "binding", "credential", "lastReceivedSequence"]) &&
requested(value) && binding(value.binding) && credential(value.credential) && sequence(value.lastReceivedSequence)) return success(value as unknown as ScreenShareClientMessage);
break;
case "screen-share-signal-request":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "binding", "credential", "targetParticipantId", "signal"]) &&
binding(value.binding) && credential(value.credential) && id(value.targetParticipantId) && signal(value.signal) &&
value.targetParticipantId !== value.credential.participantId) return success(value as unknown as ScreenShareClientMessage);
break;
case "screen-share-stop-request":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "requestId", "binding", "credential", "reason"]) &&
requested(value) && binding(value.binding) && credential(value.credential) && value.credential.role === "presenter" &&
(value.reason === "presenter-stopped" || value.reason === "capture-ended")) return success(value as unknown as ScreenShareClientMessage);
break;
case "screen-share-revoke-request":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "requestId", "binding", "credential", "scope", "targetParticipantId", "reason"]) &&
requested(value) && binding(value.binding) && credential(value.credential) &&
(value.scope === "session" || value.scope === "participant") &&
(value.targetParticipantId === null || id(value.targetParticipantId)) &&
(value.reason === "viewer-left" || value.reason === "presenter-removed-viewer" || value.reason === "moderator-action") &&
((value.scope === "session" && value.targetParticipantId === null) || (value.scope === "participant" && id(value.targetParticipantId)))) {
return success(value as unknown as ScreenShareClientMessage);
}
break;
}
return failure("invalid client message");
}
function grantEnvelope(value: RecordValue, resume: boolean): boolean {
const keys = ["type", "protocolVersion", "sequence", "timestampMs", "requestId", "binding", "grant", "nextClientSequence", "participants"];
if (resume) keys.push("continuous");
return exact(value, keys) && requested(value) && binding(value.binding) && accessGrant(value.grant) &&
sequence(value.nextClientSequence) && participants(value.participants) && (!resume || typeof value.continuous === "boolean");
}
export function parseScreenShareServerMessage(value: unknown): ScreenShareValidationResult<ScreenShareServerMessage> {
if (!plain(value) || !envelope(value) || typeof value.type !== "string") return failure("invalid server envelope");
switch (value.type) {
case "screen-share-create-grant":
if (grantEnvelope(value, false) && (value.grant as ScreenShareAccessGrant).credential.role === "presenter") return success(value as unknown as ScreenShareServerMessage);
break;
case "screen-share-join-grant":
if (grantEnvelope(value, false) && (value.grant as ScreenShareAccessGrant).credential.role === "viewer") return success(value as unknown as ScreenShareServerMessage);
break;
case "screen-share-resume-grant":
if (grantEnvelope(value, true)) return success(value as unknown as ScreenShareServerMessage);
break;
case "screen-share-participants":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "sessionId", "binding", "participants", "leaseExpiresAtMs"]) &&
id(value.sessionId) && binding(value.binding) && participants(value.participants) &&
timestamp(value.timestampMs) && timestamp(value.leaseExpiresAtMs) &&
(value.leaseExpiresAtMs as number) > (value.timestampMs as number)) return success(value as unknown as ScreenShareServerMessage);
break;
case "screen-share-signal-relay":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "sessionId", "binding", "fromParticipantId", "targetParticipantId", "signal"]) &&
id(value.sessionId) && binding(value.binding) && id(value.fromParticipantId) && id(value.targetParticipantId) &&
value.fromParticipantId !== value.targetParticipantId && signal(value.signal)) return success(value as unknown as ScreenShareServerMessage);
break;
case "screen-share-stopped":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "sessionId", "binding", "reason"]) &&
id(value.sessionId) && binding(value.binding) &&
(value.reason === "presenter-stopped" || value.reason === "capture-ended" || value.reason === "grant-expired" || value.reason === "server-shutdown")) {
return success(value as unknown as ScreenShareServerMessage);
}
break;
case "screen-share-revoked":
if (exact(value, ["type", "protocolVersion", "sequence", "timestampMs", "sessionId", "binding", "scope", "targetParticipantId", "reason", "reconnectAllowed"]) &&
id(value.sessionId) && binding(value.binding) && (value.scope === "session" || value.scope === "participant") &&
(value.targetParticipantId === null || id(value.targetParticipantId)) && typeof value.reconnectAllowed === "boolean" &&
["viewer-left", "presenter-removed-viewer", "moderator-action", "authorization-revoked", "membership-revoked", "protocol-violation", "session-replaced"].includes(String(value.reason)) &&
((value.scope === "session" && value.targetParticipantId === null) || (value.scope === "participant" && id(value.targetParticipantId)))) {
return success(value as unknown as ScreenShareServerMessage);
}
break;
}
return failure("invalid server message");
}
export function isScreenShareGrantActive(grant: ScreenShareAccessGrant, nowMs: number): boolean {
return accessGrant(grant) && timestamp(nowMs) && nowMs >= grant.issuedAtMs && nowMs < grant.expiresAtMs;
}
export function advanceScreenShareStreamCursor(
cursor: ScreenShareStreamCursor | null,
message: ScreenSharePeerMessage,
): ScreenShareValidationResult<ScreenShareStreamCursor> {
const parsed = parseScreenShareServerMessage(message);
if (!parsed.ok || !["screen-share-participants", "screen-share-signal-relay", "screen-share-stopped", "screen-share-revoked"].includes(parsed.value.type)) {
return failure("invalid peer message");
}
if (cursor && (cursor.sessionId !== message.sessionId || message.sequence <= cursor.sequence || message.timestampMs < cursor.timestampMs)) {
return failure("stale or foreign stream message");
}
return success({ sessionId: message.sessionId, sequence: message.sequence, timestampMs: message.timestampMs });
}
function success<T>(value: T): ScreenShareValidationResult<T> { return { ok: true, value }; }
function failure<T>(detail: string): ScreenShareValidationResult<T> { return { ok: false, error: `screen share: ${detail}` }; }
+343
View File
@@ -0,0 +1,343 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createRemoteOfficeMedia,
type MediaAuthorizationDecision,
type RemoteMediaTimers,
type ScreenShareAccessGrant,
type ScreenShareBinding,
type ScreenShareParticipant,
} from "../media/index.ts";
const BINDING: ScreenShareBinding = {
officeId: "lumbridge-hq",
levelId: "level-1",
roomId: "commons",
screenId: "wall-display",
};
const NOW = 1_765_000_000_000;
class FakeTrack {
readonly kind = "video";
readyState: MediaStreamTrackState = "live";
stopped = 0;
stop(): void { this.stopped += 1; this.readyState = "ended"; }
}
class FakeStream {
readonly tracks: FakeTrack[];
constructor(tracks: FakeTrack[]) { this.tracks = tracks; }
getTracks(): MediaStreamTrack[] { return this.tracks as unknown as MediaStreamTrack[]; }
getVideoTracks(): MediaStreamTrack[] { return this.getTracks(); }
}
class FakeVideo {
autoplay = true;
muted = false;
srcObject: MediaProvider | null = null;
}
class FakePeer {
localDescription: RTCSessionDescription | null = null;
remoteDescription: RTCSessionDescription | null = null;
connectionState: RTCPeerConnectionState = "new";
onicecandidate: ((this: RTCPeerConnection, ev: RTCPeerConnectionIceEvent) => unknown) | null = null;
ontrack: ((this: RTCPeerConnection, ev: RTCTrackEvent) => unknown) | null = null;
onconnectionstatechange: ((this: RTCPeerConnection, ev: Event) => unknown) | null = null;
readonly added: MediaStreamTrack[] = [];
readonly remote: RTCSessionDescriptionInit[] = [];
readonly ice: (RTCIceCandidateInit | null)[] = [];
readonly transceivers: string[] = [];
closed = 0;
addTrack(track: MediaStreamTrack): RTCRtpSender { this.added.push(track); return {} as RTCRtpSender; }
addTransceiver(kind: string): RTCRtpTransceiver { this.transceivers.push(kind); return {} as RTCRtpTransceiver; }
async createOffer(): Promise<RTCSessionDescriptionInit> { return { type: "offer", sdp: "presenter-offer" }; }
async createAnswer(): Promise<RTCSessionDescriptionInit> { return { type: "answer", sdp: "viewer-answer" }; }
async setLocalDescription(value: RTCLocalSessionDescriptionInit): Promise<void> {
this.localDescription = value as RTCSessionDescription;
}
async setRemoteDescription(value: RTCSessionDescriptionInit): Promise<void> {
this.remote.push(value);
this.remoteDescription = value as RTCSessionDescription;
}
async addIceCandidate(value?: RTCIceCandidateInit | null): Promise<void> { this.ice.push(value ?? null); }
close(): void { this.closed += 1; this.connectionState = "closed"; }
connect(): void {
this.connectionState = "connected";
this.onconnectionstatechange?.call(this as unknown as RTCPeerConnection, new Event("connectionstatechange"));
}
fail(): void {
this.connectionState = "failed";
this.onconnectionstatechange?.call(this as unknown as RTCPeerConnection, new Event("connectionstatechange"));
}
receive(track: FakeTrack, stream: FakeStream): void {
this.ontrack?.call(this as unknown as RTCPeerConnection, {
track: track as unknown as MediaStreamTrack,
streams: [stream as unknown as MediaStream],
} as unknown as RTCTrackEvent);
}
}
class FakeTimers implements RemoteMediaTimers {
pending = new Map<number, () => void>();
next = 1;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
const id = this.next++;
this.pending.set(id, callback);
return id as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout(handle: ReturnType<typeof setTimeout>): void { this.pending.delete(handle as unknown as number); }
run(): void {
const first = this.pending.entries().next().value as [number, () => void] | undefined;
if (!first) throw new Error("no reconnect timer");
this.pending.delete(first[0]);
first[1]();
}
}
function access(role: "presenter" | "viewer", participantId: string, sessionId = "share-session"): ScreenShareAccessGrant {
return {
credential: { sessionId, participantId, role, grantToken: `private-${role}-grant` },
issuedAtMs: NOW - 1_000,
expiresAtMs: NOW + 60_000,
};
}
function grant(
type: "screen-share-create-grant" | "screen-share-join-grant" | "screen-share-resume-grant",
requestId: string,
role: "presenter" | "viewer",
participants: readonly ScreenShareParticipant[],
) {
return {
type,
protocolVersion: 1,
sequence: 1,
timestampMs: NOW,
requestId,
binding: BINDING,
grant: access(role, `${role}-opaque`),
nextClientSequence: 2,
participants,
...(type === "screen-share-resume-grant" ? { continuous: true } : {}),
};
}
function sse(messages: readonly unknown[]): Response {
const bytes = new TextEncoder().encode(messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join(""));
return new Response(new ReadableStream<Uint8Array>({
start(controller) { controller.enqueue(bytes); /* held open like the real fetch stream */ },
}), { status: 200, headers: { "Content-Type": "text/event-stream" } });
}
function decision(optedIn = true): MediaAuthorizationDecision {
return {
authorized: true,
optedIn,
canView: optedIn,
reason: optedIn ? "ready" : "opt_in_required",
surface: {
...BINDING,
status: "presenting",
source: optedIn ? {
kind: "live-stream", locator: "opaque", privacy: "private", hasAudio: false, autoplay: false, muted: true,
} : null,
playback: { autoplay: false, muted: true },
},
};
}
async function settle(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
describe("remote office media presenter", () => {
it("requires opt-in, publishes one caller track, and keeps credentials in authenticated POST bodies", async () => {
const presenterTrack = new FakeTrack();
const stream = new FakeStream([presenterTrack]);
const video = new FakeVideo();
const peers: FakePeer[] = [];
const calls: Array<{ url: string; init: RequestInit; body: Record<string, unknown> }> = [];
const viewerPeer = { participantId: "viewer-opaque", role: "viewer" as const };
const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
calls.push({ url: String(input), init, body });
if (String(input).endsWith("/join")) {
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
}
if (String(input).endsWith("/events")) {
return sse([grant("screen-share-resume-grant", String(body.requestId), "presenter", [viewerPeer])]);
}
return new Response(null, { status: 204 });
};
const media = createRemoteOfficeMedia({
role: "presenter", binding: BINDING, authenticatedFetch: fetcher, now: () => NOW,
peerConnectionFactory: () => { const peer = new FakePeer(); peers.push(peer); return peer as unknown as RTCPeerConnection; },
});
await assert.rejects(
media.startPresenter({ consent: { authorized: true, optedIn: false }, stream: stream as unknown as MediaStream, video: video as unknown as HTMLVideoElement }),
/explicit opt-in/,
);
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: stream as unknown as MediaStream, video: video as unknown as HTMLVideoElement });
await settle();
assert.equal(peers.length, 1);
assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack);
assert.equal(video.autoplay, false);
assert.equal(video.muted, true);
const signal = calls.find((call) => call.url.endsWith("/signal"));
assert.equal((signal?.body.signal as { kind?: string }).kind, "sdp");
assert.equal(signal?.init.credentials, "same-origin");
assert.equal(calls.some((call) => call.url.includes("private-presenter-grant")), false);
assert.equal(JSON.stringify(media.state()).includes("private-presenter-grant"), false);
await media.stop();
assert.equal(presenterTrack.stopped, 0, "caller-owned capture track survives stop");
assert.equal(peers[0]?.closed, 1);
});
it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
const timers = new FakeTimers();
const peers: FakePeer[] = [];
const participant = { participantId: "viewer-opaque", role: "viewer" as const };
const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [participant]), { status: 201 });
if (String(input).endsWith("/events")) return sse([grant("screen-share-resume-grant", String(body.requestId), "presenter", [participant])]);
return new Response(null, { status: 204 });
};
const media = createRemoteOfficeMedia({
role: "presenter", binding: BINDING, fetch: fetcher, timers, now: () => NOW,
peerConnectionFactory: () => { const peer = new FakePeer(); peers.push(peer); return peer as unknown as RTCPeerConnection; },
});
const track = new FakeTrack();
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: new FakeStream([track]) as unknown as MediaStream, video: new FakeVideo() as unknown as HTMLVideoElement });
peers[0]?.fail();
assert.equal(media.state().status, "reconnecting");
assert.equal(peers[0]?.closed, 1);
timers.run();
await settle();
assert.equal(peers.length, 2);
assert.equal(peers[1]?.added[0], track as unknown as MediaStreamTrack);
await media.dispose();
assert.equal(track.stopped, 0);
});
it("does not resurrect a session when stop overtakes a slow join", async () => {
let resolveJoin: ((response: Response) => void) | null = null;
let joinRequestId = "";
const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
if (String(input).endsWith("/join")) {
joinRequestId = String(body.requestId);
return new Promise<Response>((resolve) => { resolveJoin = resolve; });
}
return new Response(null, { status: 204 });
};
const track = new FakeTrack();
const media = createRemoteOfficeMedia({ role: "presenter", binding: BINDING, fetch: fetcher, now: () => NOW });
const starting = media.startPresenter({
consent: { authorized: true, optedIn: true },
stream: new FakeStream([track]) as unknown as MediaStream,
video: new FakeVideo() as unknown as HTMLVideoElement,
});
await media.stop();
assert.ok(resolveJoin);
(resolveJoin as (response: Response) => void)(Response.json(
grant("screen-share-create-grant", joinRequestId, "presenter", []), { status: 201 },
));
await assert.rejects(starting, /superseded/);
assert.equal(media.state().status, "stopped");
assert.equal(media.state().sessionId, null);
assert.equal(track.stopped, 0);
});
it("renews its lease before expiry and uses only the rotated in-memory grant", async () => {
const timers = new FakeTimers();
const eventTokens: string[] = [];
let eventCalls = 0;
const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
if (String(input).endsWith("/join")) {
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
}
if (String(input).endsWith("/events")) {
eventCalls += 1;
const credential = body.credential as { grantToken: string };
eventTokens.push(credential.grantToken);
const resumed = grant("screen-share-resume-grant", String(body.requestId), "presenter", []);
resumed.grant.credential.grantToken = `rotated-${eventCalls}`;
return sse([resumed]);
}
return new Response(null, { status: 204 });
};
const media = createRemoteOfficeMedia({
role: "presenter", binding: BINDING, fetch: fetcher, timers, now: () => NOW,
peerConnectionFactory: () => new FakePeer() as unknown as RTCPeerConnection,
});
await media.startPresenter({
consent: { authorized: true, optedIn: true },
stream: new FakeStream([new FakeTrack()]) as unknown as MediaStream,
video: new FakeVideo() as unknown as HTMLVideoElement,
});
await settle();
assert.deepEqual(eventTokens, ["private-presenter-grant"]);
assert.equal(timers.pending.size, 1, "one pre-expiry renewal is scheduled");
timers.run();
await settle();
assert.deepEqual(eventTokens, ["private-presenter-grant", "rotated-1"]);
assert.equal(JSON.stringify(media.state()).includes("rotated-2"), false);
await media.dispose();
});
});
describe("remote office media viewer", () => {
it("accepts a ready decision only, answers offers, and stops receiver tracks on revoke", async () => {
const presenterPeer = { participantId: "presenter-opaque", role: "presenter" as const };
const peers: FakePeer[] = [];
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
calls.push({ url: String(input), body });
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
if (String(input).endsWith("/events")) {
const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]);
const ice = {
type: "screen-share-signal-relay", protocolVersion: 1, sequence: 2, timestampMs: NOW + 1,
sessionId: "share-session", binding: BINDING, fromParticipantId: "presenter-opaque",
targetParticipantId: "viewer-opaque",
signal: { kind: "ice-candidate", candidate: "candidate:remote", sdpMid: "0", sdpMLineIndex: 0, usernameFragment: null },
};
const relay = {
type: "screen-share-signal-relay", protocolVersion: 1, sequence: 3, timestampMs: NOW + 2,
sessionId: "share-session", binding: BINDING, fromParticipantId: "presenter-opaque",
targetParticipantId: "viewer-opaque", signal: { kind: "sdp", descriptionType: "offer", sdp: "presenter-offer" },
};
return sse([resume, ice, relay]);
}
return new Response(null, { status: 204 });
};
const media = createRemoteOfficeMedia({
role: "viewer", binding: BINDING, authenticatedFetch: fetcher, now: () => NOW,
peerConnectionFactory: () => { const peer = new FakePeer(); peers.push(peer); return peer as unknown as RTCPeerConnection; },
});
const video = new FakeVideo();
await assert.rejects(media.startViewer({ decision: decision(false), video: video as unknown as HTMLVideoElement }), /ready live-stream/);
await media.startViewer({ decision: decision(), video: video as unknown as HTMLVideoElement });
await settle();
assert.equal(peers[0]?.transceivers[0], "video");
assert.deepEqual(peers[0]?.remote[0], { type: "offer", sdp: "presenter-offer" });
assert.equal(peers[0]?.ice[0]?.candidate, "candidate:remote", "early ICE waits for remote SDP");
assert.ok(calls.some((call) => call.url.endsWith("/signal") &&
(call.body.signal as { descriptionType?: string }).descriptionType === "answer"));
const remoteTrack = new FakeTrack();
const remoteStream = new FakeStream([remoteTrack]);
peers[0]?.receive(remoteTrack, remoteStream);
assert.equal(video.srcObject, remoteStream as unknown as MediaStream);
await media.revoke();
assert.equal(remoteTrack.stopped, 1);
assert.equal(video.srcObject, null);
assert.equal(media.state().status, "revoked");
});
});
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
advanceScreenShareStreamCursor,
isScreenShareGrantActive,
parseScreenShareClientMessage,
parseScreenShareServerMessage,
} from "../media/signalingValidation.ts";
import type {
ScreenShareAccessGrant,
ScreenShareBinding,
ScreenShareSignalRelay,
} from "../media/signalingTypes.ts";
const BINDING: ScreenShareBinding = {
officeId: "lumbridge-hq",
levelId: "level-1",
roomId: "lobby",
screenId: "lobby-monitor",
};
const CREDENTIAL = {
sessionId: "opaque-session",
participantId: "opaque-participant",
role: "viewer" as const,
grantToken: "opaque-secret-token-at-least-sixteen",
};
describe("office screen signaling protocol", () => {
it("requires literal viewer opt-in and exact JSON keys", () => {
const join = {
type: "screen-share-join-request",
protocolVersion: 1,
sequence: 0,
timestampMs: 1_000,
requestId: "join-1",
binding: BINDING,
role: "viewer",
viewerOptIn: true,
};
assert.equal(parseScreenShareClientMessage(join).ok, true);
assert.equal(parseScreenShareClientMessage({ ...join, viewerOptIn: false }).ok, false);
assert.equal(parseScreenShareClientMessage({ ...join, subject: "identity-leak" }).ok, false);
assert.equal(parseScreenShareClientMessage({ ...join, locator: "https://media.invalid" }).ok, false);
});
it("bounds SDP, ICE and disallows self-signaling", () => {
const request = {
type: "screen-share-signal-request",
protocolVersion: 1,
sequence: 1,
timestampMs: 1_001,
binding: BINDING,
credential: CREDENTIAL,
targetParticipantId: "opaque-presenter",
signal: { kind: "sdp", descriptionType: "answer", sdp: "v=0" },
};
assert.equal(parseScreenShareClientMessage(request).ok, true);
assert.equal(parseScreenShareClientMessage({ ...request, targetParticipantId: CREDENTIAL.participantId }).ok, false);
assert.equal(parseScreenShareClientMessage({
...request,
signal: { ...request.signal, sdp: "x".repeat(24 * 1024 + 1) },
}).ok, false);
});
it("accepts server-issued opaque peer snapshots but no credentials in fan-out", () => {
const message = {
type: "screen-share-participants",
protocolVersion: 1,
sequence: 3,
timestampMs: 1_003,
sessionId: CREDENTIAL.sessionId,
binding: BINDING,
participants: [{ participantId: "opaque-presenter", role: "presenter" }],
leaseExpiresAtMs: 2_000,
};
assert.equal(parseScreenShareServerMessage(message).ok, true);
assert.equal(parseScreenShareServerMessage({
...message,
participants: [{ ...message.participants[0], grantToken: "leak" }],
}).ok, false);
assert.equal(parseScreenShareServerMessage({
...message,
participants: Array.from({ length: 8 }, (_, index) => ({ participantId: `viewer-${index}`, role: "viewer" })),
}).ok, false);
});
it("checks grant time windows without exposing bearer material in state", () => {
const grant: ScreenShareAccessGrant = { credential: CREDENTIAL, issuedAtMs: 1_000, expiresAtMs: 2_000 };
assert.equal(isScreenShareGrantActive(grant, 1_000), true);
assert.equal(isScreenShareGrantActive(grant, 1_999), true);
assert.equal(isScreenShareGrantActive(grant, 2_000), false);
assert.equal(isScreenShareGrantActive(grant, 999), false);
});
it("advances only same-session monotonic peer messages", () => {
const relay: ScreenShareSignalRelay = {
type: "screen-share-signal-relay",
protocolVersion: 1,
sequence: 5,
timestampMs: 1_005,
sessionId: CREDENTIAL.sessionId,
binding: BINDING,
fromParticipantId: "opaque-presenter",
targetParticipantId: CREDENTIAL.participantId,
signal: { kind: "ice-complete" },
};
const first = advanceScreenShareStreamCursor(null, relay);
assert.equal(first.ok, true);
if (!first.ok) return;
assert.equal(advanceScreenShareStreamCursor(first.value, relay).ok, false);
assert.equal(advanceScreenShareStreamCursor(first.value, { ...relay, sequence: 6, sessionId: "foreign" }).ok, false);
assert.equal(advanceScreenShareStreamCursor(first.value, { ...relay, sequence: 6, timestampMs: 1_004 }).ok, false);
assert.equal(advanceScreenShareStreamCursor(first.value, { ...relay, sequence: 6, timestampMs: 1_006 }).ok, true);
});
});