feat: add private office media signaling
This commit is contained in:
+39
-3
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createMediaSignalService,
|
||||
type MediaSignalFailureCode,
|
||||
type MediaSignalResult,
|
||||
type MediaSignalService,
|
||||
type MediaSignalServiceOptions,
|
||||
} from "./service.ts";
|
||||
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}` }; }
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user