feat: add private office media signaling
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user