1
0

feat: add private office media signaling

This commit is contained in:
2026-08-11 20:48:19 -07:00
parent fc1f500019
commit d841575315
14 changed files with 2342 additions and 3 deletions
+39 -3
View File
@@ -35,10 +35,15 @@ without breaking the typecheck, which is the wrong order to find out.
| `POST /api/v1/realtime/events` | — | fetch-streamed SSE, opened with a strict `ResumeRequest` | **private; never cached** |
| `POST /api/v1/realtime/pose` | — | one owned `EntityPoseSnapshot` | **private; never cached** |
| `POST /api/v1/realtime/leave` | — | session id + opaque resume token | **private; never cached** |
| `POST /api/v1/media/join` | — | strict create or explicitly opted-in join request | **private; never cached** |
| `POST /api/v1/media/events` | — | fetch-streamed SSE opened with a strict resume request | **private; never cached** |
| `POST /api/v1/media/signal` | — | strict targeted SDP/ICE request | **private; never cached** |
| `POST /api/v1/media/leave` | — | strict presenter stop or revoke request | **private; never cached** |
Every body is declared once, in `src/server/wire.ts` in the **root** package
type-only, so it compiles to nothing and both the browser build and this service
import the same declarations without either becoming a dependency of the other.
Every body is declared once in the **root** package: ordinary feeds live in
`src/server/wire.ts`, realtime in `src/realtime`, and screen signaling in
`src/media/signalingTypes.ts`. The browser and service import the same strict
contracts without either transport becoming a dependency of the other.
Both location parameters are optional and omitting them answers for the default
region, which is the first entry in `TERA_REGIONS`. Giving both `city` and a
@@ -75,6 +80,37 @@ or room may be joined. The three public demo offices bundled into this repo may
use their office envelope without a duplicate server pack; that exception does
not invent floor/room access and does not apply to arbitrary tenant ids.
### Office screen signaling
Office screen sharing uses a separate, in-memory signaling service; it never
enters the game-state realtime service. The server stores only short-lived SDP
and ICE messages, hashed capability grants and server-only auth subjects. It
does not receive or store media, track data, recordings, stream URLs or source
locators. All capabilities travel in authenticated POST bodies, including the
fetch-streamed SSE request, never in a query string.
A share binds to an exact authored `{officeId, levelId, roomId, screenId}`. The
server resolves the office pack and confirms that the id is a monitor/display
prop at that level and in that room. The only no-disk exception is the exact
screen catalogue in the three public bundled demo packs. A viewer joins by that
binding and must send literal `viewerOptIn: true`; the server finds the active
presenter and issues opaque session/participant ids. One presenter and at most
seven viewers are allowed. Complete participant snapshots let the presenter
create and close one peer connection per authorized viewer.
This is currently an **authenticated deployment-member policy**, because the
existing auth model knows only signed-in member versus configured global admin.
It is not a tenant or per-office membership ACL. Add an authoritative
office-membership provider before using these routes for tenant-isolated offices.
Grants are random, stored only as SHA-256 hashes, rotated on resume and leased
for a short period. Signal queues, sessions, participants and request rates are
bounded. Stop, leave, expiry and revocation remove server capabilities and send
best-effort terminal/snapshot events to connected peers. They cannot instantly
terminate media already flowing through an established WebRTC connection;
clients must close missing/revoked peers, and the short lease bounds disconnected
clients that miss an event.
## Regions
**A caller's coordinate is never forwarded upstream. It only selects among the
+2
View File
@@ -18,6 +18,7 @@ import { loadConfig, type Config } from "./config.ts";
import { registerFlights } from "./routes/flights.ts";
import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
import { registerMedia } from "./routes/media.ts";
import { registerOffices } from "./routes/offices.ts";
import { registerPresence } from "./routes/presence.ts";
import { registerRealtime } from "./routes/realtime.ts";
@@ -50,6 +51,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
registerSatellites(app, services);
registerWeather(app, services);
registerMarkers(app, services);
registerMedia(app, services);
registerOffices(app, services);
registerPresence(app, services);
registerRealtime(app, services);
+41
View File
@@ -0,0 +1,41 @@
/** Server-authoritative lookup for authored office screen surfaces. */
import { Plan } from "../../../src/interiors/plan.ts";
import type { Office } from "../../../src/interiors/types.ts";
import FRONTIER_VALLEY from "../../../src/offices/frontier-valley.ts";
import LUMBRIDGE_HQ from "../../../src/offices/lumbridge-hq.ts";
import MATEO_COURT from "../../../src/offices/mateo-court.ts";
import type { ScreenShareBinding } from "../../../src/media/signalingTypes.ts";
const SCREEN_KINDS = new Set([
"tera:screen.monitor",
"tera:screen.wall-display",
]);
const BUNDLED_OFFICES: ReadonlyMap<string, Office> = new Map(
[LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT].map((office) => [office.id, office]),
);
/**
* A bundled pack is public sample geometry, so its exact authored screen
* catalogue is the only bundled exception. No real/private pack is inferred.
*/
export function bundledMediaOffice(officeId: string): Office | null {
return BUNDLED_OFFICES.get(officeId) ?? null;
}
/**
* Confirm an exact client binding against a validated/resolved office pack.
* Room membership is computed from the prop's authored position rather than
* trusting a caller-provided label.
*/
export function officeHasMediaBinding(office: Office, binding: ScreenShareBinding): boolean {
if (office.id !== binding.officeId) return false;
const plan = new Plan(office, { depth: "full", warn: false });
const level = plan.level(binding.levelId);
if (!level) return false;
const prop = level.props.find((candidate) => candidate.id === binding.screenId);
if (!prop || !SCREEN_KINDS.has(prop.kind)) return false;
const roomId = plan.roomAt(level.id, prop.position)?.id ?? null;
return roomId === binding.roomId;
}
+8
View File
@@ -0,0 +1,8 @@
export {
createMediaSignalService,
type MediaSignalFailureCode,
type MediaSignalResult,
type MediaSignalService,
type MediaSignalServiceOptions,
} from "./service.ts";
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
+406
View File
@@ -0,0 +1,406 @@
/** In-memory WebRTC signaling only: no tracks, media bytes, locators, or persistence. */
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import {
MAX_SCREEN_SHARE_PARTICIPANTS,
SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
type ScreenShareAccessGrant,
type ScreenShareBinding,
type ScreenShareCreateGrant,
type ScreenShareCreateRequest,
type ScreenShareCredential,
type ScreenShareJoinGrant,
type ScreenShareJoinRequest,
type ScreenShareParticipant,
type ScreenSharePeerMessage,
type ScreenShareResumeGrant,
type ScreenShareResumeRequest,
type ScreenShareRevokeRequest,
type ScreenShareRevoked,
type ScreenShareRole,
type ScreenShareSignalRelay,
type ScreenShareSignalRequest,
type ScreenShareStopRequest,
type ScreenShareStopped,
} from "../../../src/media/signalingTypes.ts";
export type MediaSignalFailureCode =
| "invalid" | "unauthorized" | "expired" | "conflict" | "capacity" | "rate-limited";
export type MediaSignalResult<T> =
| { ok: true; value: T }
| { ok: false; code: MediaSignalFailureCode; message: string };
export interface MediaSignalServiceOptions {
now?: () => number;
grantTtlMs?: number;
maximumSessions?: number;
maximumParticipantsPerSession?: number;
maximumQueuedMessagesPerParticipant?: number;
maximumSignalsPerWindow?: number;
rateWindowMs?: number;
}
export interface MediaSignalService {
join(request: ScreenShareCreateRequest | ScreenShareJoinRequest, subject: string): MediaSignalResult<ScreenShareCreateGrant | ScreenShareJoinGrant>;
resume(request: ScreenShareResumeRequest, subject: string): MediaSignalResult<ScreenShareResumeGrant>;
signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null>;
leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null>;
subscribe(credential: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void>;
revalidate(credential: ScreenShareCredential, subject: string): MediaSignalResult<null>;
revokeSubject(subject: string): number;
cleanup(): number;
sessionCount(): number;
}
interface Participant {
id: string;
subject: string;
role: ScreenShareRole;
tokenHash: Buffer;
issuedAt: number;
expiresAt: number;
lastClientSequence: number;
rateStartedAt: number;
rateCount: number;
queue: ScreenSharePeerMessage[];
listeners: Set<(message: ScreenSharePeerMessage) => void>;
}
interface Session {
id: string;
binding: ScreenShareBinding;
sequence: number;
participants: Map<string, Participant>;
}
export function createMediaSignalService(options: MediaSignalServiceOptions = {}): MediaSignalService {
const now = options.now ?? Date.now;
const ttl = positive(options.grantTtlMs, 90_000);
const maximumSessions = count(options.maximumSessions, 128);
const maximumParticipants = Math.min(
MAX_SCREEN_SHARE_PARTICIPANTS,
count(options.maximumParticipantsPerSession, MAX_SCREEN_SHARE_PARTICIPANTS),
);
const maximumQueued = count(options.maximumQueuedMessagesPerParticipant, 64);
const maximumSignals = count(options.maximumSignalsPerWindow, 120);
const rateWindowMs = positive(options.rateWindowMs, 10_000);
const sessions = new Map<string, Session>();
function peerList(session: Session, except: string): ScreenShareParticipant[] {
return [...session.participants.values()]
.filter((participant) => participant.id !== except)
.map(({ id: participantId, role }) => ({ participantId, role }));
}
function nextSequence(session: Session): number {
session.sequence = Math.min(Number.MAX_SAFE_INTEGER, session.sequence + 1);
return session.sequence;
}
function stopNotice(session: Session, reason: ScreenShareStopped["reason"]): ScreenShareStopped {
return { type: "screen-share-stopped", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id,
binding: session.binding, reason };
}
function credential(session: Session, participant: Participant, token: string): ScreenShareCredential {
return { sessionId: session.id, participantId: participant.id, role: participant.role, grantToken: token };
}
function grant(session: Session, participant: Participant, token: string): ScreenShareAccessGrant {
return { credential: credential(session, participant, token), issuedAtMs: participant.issuedAt, expiresAtMs: participant.expiresAt };
}
function emit(participant: Participant, message: ScreenSharePeerMessage): void {
if (participant.listeners.size > 0) {
for (const listener of participant.listeners) listener(message);
return;
}
if (participant.queue.length >= maximumQueued) {
// Resume is deliberately discontinuous and carries a complete peer
// snapshot, so dropping the oldest relay is safer than letting a sender
// evict another participant by filling its queue.
participant.queue.shift();
}
participant.queue.push(message);
}
function participantSnapshot(session: Session): void {
const timestampMs = now();
for (const participant of [...session.participants.values()]) {
emit(participant, {
type: "screen-share-participants",
protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session),
timestampMs,
sessionId: session.id,
binding: session.binding,
participants: peerList(session, participant.id),
leaseExpiresAtMs: participant.expiresAt,
});
}
}
function destroySession(session: Session, notice?: ScreenShareStopped | ScreenShareRevoked): void {
// Terminal messages are best effort for connected peers. Queuing one and
// then immediately destroying the queue would be dishonest, while routing
// through `emit` on a full queue could recurse into participant removal.
if (notice) {
for (const participant of session.participants.values()) {
for (const listener of participant.listeners) listener(notice);
}
}
sessions.delete(session.id);
for (const participant of session.participants.values()) {
participant.tokenHash.fill(0);
participant.queue.length = 0;
participant.listeners.clear();
}
session.participants.clear();
}
function removeParticipant(
session: Session,
participant: Participant,
reason: ScreenShareRevoked["reason"] = "viewer-left",
): void {
const notice: ScreenShareRevoked = {
type: "screen-share-revoked",
protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session),
timestampMs: now(),
sessionId: session.id,
binding: session.binding,
scope: "participant",
targetParticipantId: participant.id,
reason,
reconnectAllowed: false,
};
for (const listener of participant.listeners) listener(notice);
session.participants.delete(participant.id);
participant.tokenHash.fill(0);
participant.queue.length = 0;
participant.listeners.clear();
participantSnapshot(session);
}
function authenticate(raw: ScreenShareCredential, subject: string): MediaSignalResult<{ session: Session; participant: Participant }> {
const session = sessions.get(raw.sessionId);
const participant = session?.participants.get(raw.participantId);
if (!session || !participant || participant.subject !== subject || participant.role !== raw.role ||
!tokenMatches(participant.tokenHash, raw.grantToken)) {
return { ok: false, code: "unauthorized", message: "Media signaling credential is invalid." };
}
if (now() >= participant.expiresAt) {
if (participant.role === "presenter") {
destroySession(session, stopNotice(session, "grant-expired"));
} else removeParticipant(session, participant, "authorization-revoked");
return { ok: false, code: "expired", message: "Media signaling grant expired." };
}
return { ok: true, value: { session, participant } };
}
function validateRequest(
session: Session,
participant: Participant,
binding: ScreenShareBinding,
sequence: number,
): MediaSignalResult<null> {
if (!sameBinding(session.binding, binding)) return { ok: false, code: "unauthorized", message: "Media binding does not match the grant." };
if (!Number.isSafeInteger(sequence) || sequence <= participant.lastClientSequence) {
return { ok: false, code: "conflict", message: "Media client sequence is not monotonic." };
}
participant.lastClientSequence = sequence;
return { ok: true, value: null };
}
function newParticipant(subject: string, role: ScreenShareRole, clientSequence: number): { participant: Participant; token: string } {
const token = opaque(32);
const issuedAt = now();
return { token, participant: {
id: opaque(18), subject, role, tokenHash: hash(token), issuedAt, expiresAt: issuedAt + ttl,
lastClientSequence: clientSequence, rateStartedAt: issuedAt, rateCount: 0, queue: [], listeners: new Set(),
} };
}
function join(request: ScreenShareCreateRequest | ScreenShareJoinRequest, subject: string): MediaSignalResult<ScreenShareCreateGrant | ScreenShareJoinGrant> {
cleanup();
if (request.type === "screen-share-create-request") {
if (sessions.size >= maximumSessions) return { ok: false, code: "capacity", message: "Media signaling is at capacity." };
if ([...sessions.values()].some((candidate) => sameBinding(candidate.binding, request.binding))) {
return { ok: false, code: "conflict", message: "That screen already has an active presenter." };
}
const session: Session = { id: opaque(18), binding: copyBinding(request.binding), sequence: 0, participants: new Map() };
const made = newParticipant(subject, "presenter", request.sequence);
session.participants.set(made.participant.id, made.participant);
sessions.set(session.id, session);
return { ok: true, value: {
type: "screen-share-create-grant", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
requestId: request.requestId, sequence: nextSequence(session), timestampMs: now(), binding: session.binding,
grant: grant(session, made.participant, made.token) as ScreenShareAccessGrant<"presenter">,
nextClientSequence: request.sequence + 1, participants: [],
} };
}
const session = [...sessions.values()].find((candidate) => sameBinding(candidate.binding, request.binding));
if (!session) return { ok: false, code: "conflict", message: "That screen has no active presenter." };
if (session.participants.size >= maximumParticipants) return { ok: false, code: "capacity", message: "Media screen is at capacity." };
if ([...session.participants.values()].some((participant) => participant.subject === subject)) {
return { ok: false, code: "conflict", message: "Member is already active on that screen." };
}
const made = newParticipant(subject, "viewer", request.sequence);
const existing = peerList(session, made.participant.id);
session.participants.set(made.participant.id, made.participant);
participantSnapshot(session);
return { ok: true, value: {
type: "screen-share-join-grant", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
requestId: request.requestId, sequence: nextSequence(session), timestampMs: now(), binding: session.binding,
grant: grant(session, made.participant, made.token) as ScreenShareAccessGrant<"viewer">,
nextClientSequence: request.sequence + 1, participants: existing,
} };
}
function resume(request: ScreenShareResumeRequest, subject: string): MediaSignalResult<ScreenShareResumeGrant> {
const found = authenticate(request.credential, subject);
if (!found.ok) return found;
const { session, participant } = found.value;
const valid = validateRequest(session, participant, request.binding, request.sequence);
if (!valid.ok) return valid;
const token = opaque(32);
participant.tokenHash.fill(0);
participant.tokenHash = hash(token);
participant.issuedAt = now();
participant.expiresAt = now() + ttl;
// A resume grant carries a complete participant snapshot. Dropping queued
// relays avoids replaying a message with a sequence older than that grant;
// clients renegotiate when `continuous` is false.
participant.queue.length = 0;
return { ok: true, value: {
type: "screen-share-resume-grant", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
requestId: request.requestId, sequence: nextSequence(session), timestampMs: now(), binding: session.binding,
grant: grant(session, participant, token), nextClientSequence: request.sequence + 1,
participants: peerList(session, participant.id), continuous: false,
} };
}
function signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult<null> {
const found = authenticate(request.credential, subject);
if (!found.ok) return found;
const { session, participant } = found.value;
const valid = validateRequest(session, participant, request.binding, request.sequence);
if (!valid.ok) return valid;
if (now() - participant.rateStartedAt >= rateWindowMs) { participant.rateStartedAt = now(); participant.rateCount = 0; }
if (++participant.rateCount > maximumSignals) return { ok: false, code: "rate-limited", message: "Media signaling rate exceeded." };
const target = session.participants.get(request.targetParticipantId);
if (!target || target.role === participant.role) return { ok: false, code: "unauthorized", message: "Media signaling target is not permitted." };
if (request.signal.kind === "sdp") {
const permitted = participant.role === "presenter" ? request.signal.descriptionType === "offer" : request.signal.descriptionType === "answer";
if (!permitted) return { ok: false, code: "unauthorized", message: "Media SDP role is not permitted." };
}
const relay: ScreenShareSignalRelay = {
type: "screen-share-signal-relay", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding,
fromParticipantId: participant.id, targetParticipantId: target.id, signal: request.signal,
};
emit(target, relay);
return { ok: true, value: null };
}
function leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult<null> {
const found = authenticate(request.credential, subject);
if (!found.ok) return found;
const { session, participant } = found.value;
const valid = validateRequest(session, participant, request.binding, request.sequence);
if (!valid.ok) return valid;
if (request.type === "screen-share-stop-request") {
if (participant.role !== "presenter") return { ok: false, code: "unauthorized", message: "Only a presenter may stop a share." };
destroySession(session, stopNotice(session, request.reason));
return { ok: true, value: null };
}
if (request.reason === "moderator-action" && !admin) return { ok: false, code: "unauthorized", message: "Moderator action requires an administrator." };
if (request.scope === "session") {
if (participant.role !== "presenter" && !admin) return { ok: false, code: "unauthorized", message: "Only a presenter or administrator may revoke a session." };
destroySession(session, {
type: "screen-share-revoked", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding,
scope: "session", targetParticipantId: null, reason: request.reason, reconnectAllowed: false,
});
return { ok: true, value: null };
}
const targetId = request.targetParticipantId ?? participant.id;
const target = session.participants.get(targetId);
if (!target) return { ok: false, code: "invalid", message: "Media participant is unavailable." };
if (target.id !== participant.id && participant.role !== "presenter" && !admin) {
return { ok: false, code: "unauthorized", message: "Viewer may only leave its own session." };
}
if (target.role === "presenter") return { ok: false, code: "unauthorized", message: "Presenter must stop the session." };
removeParticipant(session, target, request.reason);
return { ok: true, value: null };
}
function subscribe(raw: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void> {
const found = authenticate(raw, subject);
if (!found.ok) return found;
const participant = found.value.participant;
for (const message of participant.queue.splice(0)) listener(message);
participant.listeners.add(listener);
return { ok: true, value: () => participant.listeners.delete(listener) };
}
function revalidate(raw: ScreenShareCredential, subject: string): MediaSignalResult<null> {
const found = authenticate(raw, subject);
return found.ok ? { ok: true, value: null } : found;
}
function revokeSubject(subject: string): number {
let removed = 0;
for (const session of [...sessions.values()]) {
const matching = [...session.participants.values()].filter((participant) => participant.subject === subject);
const presenter = matching.find((participant) => participant.role === "presenter");
if (presenter) {
destroySession(session, stopNotice(session, "server-shutdown"));
removed += 1;
continue;
}
for (const participant of matching) {
removeParticipant(session, participant, "authorization-revoked");
removed += 1;
}
}
return removed;
}
function cleanup(): number {
let removed = 0;
for (const session of [...sessions.values()]) {
const expired = [...session.participants.values()].filter((participant) => now() >= participant.expiresAt);
const presenter = expired.find((participant) => participant.role === "presenter");
if (presenter) {
destroySession(session, stopNotice(session, "grant-expired"));
removed += 1;
continue;
}
for (const participant of expired) {
removeParticipant(session, participant, "authorization-revoked");
removed += 1;
}
}
return removed;
}
return { join, resume, signal, leave, subscribe, revalidate, revokeSubject, cleanup, sessionCount: () => sessions.size };
}
function opaque(bytes: number): string { return randomBytes(bytes).toString("base64url"); }
function hash(token: string): Buffer { return createHash("sha256").update(token).digest(); }
function tokenMatches(expected: Buffer, token: string): boolean {
if (typeof token !== "string" || token.length < 16 || token.length > 512) return false;
const actual = hash(token); return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function positive(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function count(value: number | undefined, fallback: number): number { return Math.max(1, Math.floor(positive(value, fallback))); }
function copyBinding(binding: ScreenShareBinding): ScreenShareBinding { return { ...binding }; }
function sameBinding(a: ScreenShareBinding, b: ScreenShareBinding): boolean {
return a.officeId === b.officeId && a.levelId === b.levelId && a.roomId === b.roomId && a.screenId === b.screenId;
}
+145
View File
@@ -0,0 +1,145 @@
/** Authenticated, POST-body-only office WebRTC signaling routes. */
import type { FastifyInstance, FastifyReply } from "fastify";
import { parseScreenShareClientMessage } from "../../../src/media/signalingValidation.ts";
import type {
ScreenShareBinding,
ScreenShareCreateRequest,
ScreenShareJoinRequest,
ScreenShareResumeRequest,
ScreenShareRevokeRequest,
ScreenShareSignalRequest,
ScreenShareStopRequest,
} from "../../../src/media/signalingTypes.ts";
import { bundledMediaOffice, officeHasMediaBinding, type MediaSignalFailureCode } from "../media/index.ts";
import type { Services } from "../services.ts";
const BODY_LIMIT = 32 * 1024;
const REVALIDATE_MS = 15_000;
const UNAUTHORIZED = { error: "unauthorized", message: "Media signaling requires a signed-in member." };
function status(code: MediaSignalFailureCode): number {
if (code === "unauthorized" || code === "expired") return 401;
if (code === "conflict") return 409;
if (code === "capacity" || code === "rate-limited") return 429;
return 400;
}
function fail(reply: FastifyReply, failure: { code: MediaSignalFailureCode; message: string }) {
const code = status(failure.code);
if (code === 401) reply.header("www-authenticate", "Bearer");
return reply.code(code).send({ error: failure.code, message: failure.message });
}
async function bindingAllowed(binding: ScreenShareBinding, services: Services): Promise<boolean> {
const doc = await services.offices.get(binding.officeId);
const office = doc?.floor ?? bundledMediaOffice(binding.officeId);
return office !== null && officeHasMediaBinding(office, binding);
}
function parse(body: unknown) {
const parsed = parseScreenShareClientMessage(body);
return parsed.ok ? parsed.value : null;
}
export function registerMedia(app: FastifyInstance, services: Services): void {
app.post<{ Body: unknown }>("/api/v1/media/join", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const message = parse(req.body);
if (!message || (message.type !== "screen-share-create-request" && message.type !== "screen-share-join-request")) {
return reply.code(400).send({ error: "invalid", message: "Media join request is invalid." });
}
// Authentication precedes lookup so anonymous callers cannot enumerate private packs.
if (!(await bindingAllowed(message.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const joined = services.media.join(message as ScreenShareCreateRequest | ScreenShareJoinRequest, viewer.subject);
if (!joined.ok) return fail(reply, joined);
return reply.code(201).send(joined.value);
});
app.post<{ Body: unknown }>("/api/v1/media/signal", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const message = parse(req.body);
if (!message || message.type !== "screen-share-signal-request") {
return reply.code(400).send({ error: "invalid", message: "Media signal request is invalid." });
}
if (!(await bindingAllowed(message.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const signaled = services.media.signal(message as ScreenShareSignalRequest, viewer.subject);
if (!signaled.ok) return fail(reply, signaled);
return reply.code(204).send();
});
app.post<{ Body: unknown }>("/api/v1/media/events", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const subject = viewer.subject;
const message = parse(req.body);
if (!message || message.type !== "screen-share-resume-request") {
return reply.code(400).send({ error: "invalid", message: "Media resume request is invalid." });
}
if (!(await bindingAllowed(message.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const resumed = services.media.resume(message as ScreenShareResumeRequest, subject);
if (!resumed.ok) return fail(reply, resumed);
reply.hijack();
reply.raw.writeHead(200, {
"cache-control": "private, no-store",
connection: "keep-alive",
"content-type": "text/event-stream; charset=utf-8",
"x-accel-buffering": "no",
});
const write = (value: unknown) => reply.raw.write(`data: ${JSON.stringify(value)}\n\n`);
write(resumed.value);
const activeCredential = resumed.value.grant.credential;
const subscribed = services.media.subscribe(activeCredential, subject, write);
if (!subscribed.ok) { reply.raw.end(); return; }
const interval = setInterval(() => {
// Re-resolve the caller, rather than treating the identity result from
// stream-open as permanent. This bounds issuer revocation and JWT expiry
// even while WebRTC itself continues peer-to-peer.
void services.auth.resolve(req).then((current) => {
if (!current.authenticated || current.subject !== subject) {
reply.raw.end();
return;
}
const valid = services.media.revalidate(activeCredential, subject);
if (!valid.ok) reply.raw.end();
else reply.raw.write(": keepalive\n\n");
}).catch(() => reply.raw.end());
}, REVALIDATE_MS);
interval.unref();
// The POST request body closes as soon as it is parsed; the response owns
// the fetch-streamed SSE lifetime.
reply.raw.once("close", () => { clearInterval(interval); subscribed.value(); });
});
app.post<{ Body: unknown }>("/api/v1/media/leave", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const message = parse(req.body);
if (!message || (message.type !== "screen-share-stop-request" && message.type !== "screen-share-revoke-request")) {
return reply.code(400).send({ error: "invalid", message: "Media leave request is invalid." });
}
if (!(await bindingAllowed(message.binding, services))) {
return reply.code(404).send({ error: "not_found", message: "No such authored office screen." });
}
const left = services.media.leave(message as ScreenShareStopRequest | ScreenShareRevokeRequest, viewer.subject, viewer.admin);
if (!left.ok) return fail(reply, left);
return reply.code(204).send();
});
}
+3
View File
@@ -10,6 +10,7 @@
import { createAuth, type AuthService } from "./auth/index.ts";
import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
import { createMediaSignalService, type MediaSignalService } from "./media/index.ts";
import { createOfficeStore, type OfficeStore } from "./offices/store.ts";
import { createPresenceStore, type PresenceStore } from "./presence/store.ts";
import { createRealtimeService, type RealtimeService } from "./realtime/index.ts";
@@ -23,6 +24,7 @@ export interface Services {
flights: FlightsService;
satellites: SatellitesService;
markers: MarkerStore;
media: MediaSignalService;
offices: OfficeStore;
presence: PresenceStore;
realtime: RealtimeService;
@@ -43,6 +45,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
flights: createFlightsService(config, log),
satellites: createSatellitesService(config, log),
markers: createMarkerStore(config, log),
media: createMediaSignalService(),
offices: createOfficeStore(config.offices.dir),
presence: createPresenceStore(config.presence.dir),
realtime: createRealtimeService(),
+166
View File
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { after, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { createMediaSignalService, officeHasMediaBinding } from "../media/index.ts";
import LUMBRIDGE_HQ from "../../../src/offices/lumbridge-hq.ts";
import type {
ScreenShareBinding,
ScreenShareCreateRequest,
ScreenShareJoinRequest,
ScreenShareRevokeRequest,
ScreenShareSignalRequest,
} from "../../../src/media/signalingTypes.ts";
const BINDING: ScreenShareBinding = {
officeId: "lumbridge-hq", levelId: "level-1", roomId: "lobby", screenId: "lobby-monitor",
};
const create = (sequence = 1): ScreenShareCreateRequest => ({
type: "screen-share-create-request", protocolVersion: 1, requestId: "request-create", sequence,
timestampMs: 1_000, binding: BINDING, role: "presenter",
});
const join = (sequence = 1): ScreenShareJoinRequest => ({
type: "screen-share-join-request", protocolVersion: 1, requestId: "request-join", sequence,
timestampMs: 1_000, binding: BINDING, role: "viewer", viewerOptIn: true,
});
function grants(service: ReturnType<typeof createMediaSignalService>) {
const presenter = service.join(create(), "presenter-subject");
const viewer = service.join(join(), "viewer-subject");
assert.equal(presenter.ok, true); assert.equal(viewer.ok, true);
if (!presenter.ok || !viewer.ok || presenter.value.type !== "screen-share-create-grant" || viewer.value.type !== "screen-share-join-grant") {
throw new Error("grant setup failed");
}
return { presenter: presenter.value, viewer: viewer.value };
}
describe("office media signaling service", () => {
it("issues opaque participants, snapshots peers, and enforces P2P signaling roles", () => {
const service = createMediaSignalService();
const { presenter, viewer } = grants(service);
const p = presenter.grant.credential; const v = viewer.grant.credential;
assert.notEqual(p.sessionId, p.participantId); assert.notEqual(p.participantId, p.grantToken);
assert.deepEqual(presenter.participants, []);
assert.deepEqual(viewer.participants, [{ participantId: p.participantId, role: "presenter" }]);
assert.equal(JSON.stringify(viewer).includes("subject"), false);
const received: string[] = [];
assert.equal(service.subscribe(v, "viewer-subject", (message) => received.push(message.type)).ok, true);
const offer: ScreenShareSignalRequest = {
type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_001,
binding: BINDING, credential: p, targetParticipantId: v.participantId,
signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0" },
};
assert.equal(service.signal(offer, "presenter-subject").ok, true);
assert.equal(received.includes("screen-share-signal-relay"), true);
assert.equal(service.signal({ ...offer, sequence: 3, credential: v, targetParticipantId: p.participantId }, "viewer-subject").ok, false);
assert.equal(service.signal({ ...offer, sequence: 4, credential: p }, "wrong-subject").ok, false);
});
it("rotates short-lived hashed grants and rejects stale capabilities", () => {
let now = 10_000;
const service = createMediaSignalService({ now: () => now, grantTtlMs: 100 });
const { presenter } = grants(service);
const old = presenter.grant.credential;
const resumed = service.resume({
type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-resume", sequence: 2,
timestampMs: now, binding: BINDING, credential: old, lastReceivedSequence: 0,
}, "presenter-subject");
assert.equal(resumed.ok, true);
if (!resumed.ok) return;
assert.notEqual(resumed.value.grant.credential.grantToken, old.grantToken);
assert.equal(service.revalidate(old, "presenter-subject").ok, false);
now += 101;
assert.equal(service.revalidate(resumed.value.grant.credential, "presenter-subject").ok, false);
assert.equal(service.sessionCount(), 0);
});
it("bounds the mesh and sends viewer lifecycle snapshots", () => {
const service = createMediaSignalService({ maximumParticipantsPerSession: 2 });
const { presenter, viewer } = grants(service);
assert.equal(service.join(join(2), "third-viewer").ok, false);
const snapshots: number[] = [];
service.subscribe(presenter.grant.credential, "presenter-subject", (message) => {
if (message.type === "screen-share-participants") snapshots.push(message.participants.length);
});
const leave: ScreenShareRevokeRequest = {
type: "screen-share-revoke-request", protocolVersion: 1, requestId: "request-leave", sequence: 2,
timestampMs: 1_002, binding: BINDING, credential: viewer.grant.credential,
scope: "participant", targetParticipantId: viewer.grant.credential.participantId, reason: "viewer-left",
};
assert.equal(service.leave(leave, "viewer-subject", false).ok, true);
assert.equal(snapshots.at(-1), 0);
});
it("validates the exact authored level, room and screen prop", () => {
assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, BINDING), true);
assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, { ...BINDING, roomId: "open-floor" }), false);
assert.equal(officeHasMediaBinding(LUMBRIDGE_HQ, { ...BINDING, screenId: "not-authored" }), false);
});
});
const SECRET = "media-route-test-secret-that-is-long-enough";
function bearer(subject: string) {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ sub: subject, exp: Math.floor(Date.now() / 1000) + 600 })}`;
return { authorization: `Bearer ${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}` };
}
describe("office media signaling routes", () => {
it("authenticates before exact lookup and keeps capabilities in POST bodies", async () => {
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET }); config.logLevel = "silent";
const app = buildApp(config); after(() => app.close());
const anonymous = await app.inject({ method: "POST", url: "/api/v1/media/join", payload: create() });
assert.equal(anonymous.statusCode, 401);
const locator = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("presenter"), payload: { ...create(), sourceUrl: "https://private.invalid/stream" } });
assert.equal(locator.statusCode, 400);
const wrongRoom = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("viewer"), payload: { ...join(), binding: { ...BINDING, roomId: "not-the-room" } } });
assert.equal(wrongRoom.statusCode, 404);
const presenter = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("presenter"), payload: create() });
const viewer = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("viewer"), payload: join() });
assert.equal(presenter.statusCode, 201); assert.equal(viewer.statusCode, 201);
assert.equal(presenter.headers["cache-control"], "private, no-store"); assert.equal(presenter.headers.location, undefined);
const p = presenter.json<{ grant: { credential: ScreenShareSignalRequest["credential"] } }>();
const v = viewer.json<{ grant: { credential: ScreenShareSignalRequest["credential"] } }>();
const sent = await app.inject({ method: "POST", url: "/api/v1/media/signal", headers: bearer("presenter"), payload: {
type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_002,
binding: BINDING, credential: p.grant.credential, targetParticipantId: v.grant.credential.participantId,
signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0" },
} });
assert.equal(sent.statusCode, 204);
const stolen = await app.inject({ method: "POST", url: "/api/v1/media/signal", headers: bearer("viewer"), payload: {
type: "screen-share-signal-request", protocolVersion: 1, sequence: 3, timestampMs: 1_003,
binding: BINDING, credential: p.grant.credential, targetParticipantId: v.grant.credential.participantId,
signal: { kind: "ice-complete" },
} });
assert.equal(stolen.statusCode, 401);
});
it("opens SSE with a POST-body resume grant and rotates the capability", async () => {
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET }); config.logLevel = "silent";
const app = buildApp(config); after(() => app.close());
const origin = await app.listen({ host: "127.0.0.1", port: 0 });
const created = await fetch(`${origin}/api/v1/media/join`, {
method: "POST", headers: { ...bearer("presenter"), "content-type": "application/json" }, body: JSON.stringify(create()),
});
const body = await created.json() as { grant: { credential: ScreenShareSignalRequest["credential"] } };
const controller = new AbortController();
const streamed = await fetch(`${origin}/api/v1/media/events`, {
method: "POST", signal: controller.signal,
headers: { ...bearer("presenter"), "content-type": "application/json" },
body: JSON.stringify({
type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-events", sequence: 2,
timestampMs: 1_003, binding: BINDING, credential: body.grant.credential, lastReceivedSequence: 0,
}),
});
assert.equal(streamed.status, 200);
assert.match(streamed.headers.get("content-type") ?? "", /^text\/event-stream/);
const first = await streamed.body?.getReader().read();
const text = new TextDecoder().decode(first?.value);
assert.match(text, /screen-share-resume-grant/);
assert.equal(text.includes(body.grant.credential.grantToken), false);
assert.equal(streamed.url.includes("grantToken"), false);
controller.abort();
});
});