diff --git a/server/README.md b/server/README.md index 23ed2aa..ee895e7 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/src/app.ts b/server/src/app.ts index 9b54ae6..9c3de75 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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); diff --git a/server/src/media/bindings.ts b/server/src/media/bindings.ts new file mode 100644 index 0000000..a99dd66 --- /dev/null +++ b/server/src/media/bindings.ts @@ -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 = 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; +} diff --git a/server/src/media/index.ts b/server/src/media/index.ts new file mode 100644 index 0000000..e1ab384 --- /dev/null +++ b/server/src/media/index.ts @@ -0,0 +1,8 @@ +export { + createMediaSignalService, + type MediaSignalFailureCode, + type MediaSignalResult, + type MediaSignalService, + type MediaSignalServiceOptions, +} from "./service.ts"; +export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts"; diff --git a/server/src/media/service.ts b/server/src/media/service.ts new file mode 100644 index 0000000..5bbe915 --- /dev/null +++ b/server/src/media/service.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 = + | { 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; + resume(request: ScreenShareResumeRequest, subject: string): MediaSignalResult; + signal(request: ScreenShareSignalRequest, subject: string): MediaSignalResult; + leave(request: ScreenShareStopRequest | ScreenShareRevokeRequest, subject: string, admin: boolean): MediaSignalResult; + subscribe(credential: ScreenShareCredential, subject: string, listener: (message: ScreenSharePeerMessage) => void): MediaSignalResult<() => void>; + revalidate(credential: ScreenShareCredential, subject: string): MediaSignalResult; + 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; +} + +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(); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/server/src/routes/media.ts b/server/src/routes/media.ts new file mode 100644 index 0000000..e307971 --- /dev/null +++ b/server/src/routes/media.ts @@ -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 { + 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(); + }); +} diff --git a/server/src/services.ts b/server/src/services.ts index e7ec385..5957613 100644 --- a/server/src/services.ts +++ b/server/src/services.ts @@ -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(), diff --git a/server/src/test/media.test.ts b/server/src/test/media.test.ts new file mode 100644 index 0000000..0dc0a2d --- /dev/null +++ b/server/src/test/media.test.ts @@ -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) { + 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(); + }); +}); diff --git a/src/media/index.ts b/src/media/index.ts index 3635dcc..6e90087 100644 --- a/src/media/index.ts +++ b/src/media/index.ts @@ -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"; diff --git a/src/media/remoteMedia.ts b/src/media/remoteMedia.ts new file mode 100644 index 0000000..ce210c2 --- /dev/null +++ b/src/media/remoteMedia.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; + clearTimeout(handle: ReturnType): 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; + 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; + startViewer(input: ViewerStart): Promise; + state(): RemoteMediaState; + stop(): Promise; + revoke(): Promise; + dispose(): Promise; +} + +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): 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 | null = null; + let renewalTimer: ReturnType | null = null; + let reconnectAttempt = 0; + let generation = 0; + const peers = new Map(); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (status === "disposed" || status === "stopped") return; + try { await leave(); } finally { presenter = null; viewer = null; setStatus("stopped"); } + } + async function revoke(): Promise { + if (status === "disposed" || status === "revoked") return; + try { await leave(); } finally { presenter = null; viewer = null; setStatus("revoked"); } + } + async function dispose(): Promise { + if (status === "disposed") return; + try { await leave(); } finally { presenter = null; viewer = null; status = "disposed"; changed(); } + } + + return { startPresenter, startViewer, state, stop, revoke, dispose }; +} diff --git a/src/media/signalingTypes.ts b/src/media/signalingTypes.ts new file mode 100644 index 0000000..098a4ee --- /dev/null +++ b/src/media/signalingTypes.ts @@ -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 { + sessionId: string; + participantId: string; + role: R; + grantToken: string; +} + +export interface ScreenShareAccessGrant { + credential: ScreenShareCredential; + 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 extends ScreenShareRequestedEnvelope { + binding: ScreenShareBinding; + grant: ScreenShareAccessGrant; + 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 { + 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 = + | { ok: true; value: T } + | { ok: false; error: string }; diff --git a/src/media/signalingValidation.ts b/src/media/signalingValidation.ts new file mode 100644 index 0000000..1a8a49c --- /dev/null +++ b/src/media/signalingValidation.ts @@ -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; + +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 { + 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 { + 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 { + 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(value: T): ScreenShareValidationResult { return { ok: true, value }; } +function failure(detail: string): ScreenShareValidationResult { return { ok: false, error: `screen share: ${detail}` }; } diff --git a/src/test/remoteMedia.test.ts b/src/test/remoteMedia.test.ts new file mode 100644 index 0000000..da6c819 --- /dev/null +++ b/src/test/remoteMedia.test.ts @@ -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 { return { type: "offer", sdp: "presenter-offer" }; } + async createAnswer(): Promise { return { type: "answer", sdp: "viewer-answer" }; } + async setLocalDescription(value: RTCLocalSessionDescriptionInit): Promise { + this.localDescription = value as RTCSessionDescription; + } + async setRemoteDescription(value: RTCSessionDescriptionInit): Promise { + this.remote.push(value); + this.remoteDescription = value as RTCSessionDescription; + } + async addIceCandidate(value?: RTCIceCandidateInit | null): Promise { 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 void>(); + next = 1; + setTimeout(callback: () => void): ReturnType { + const id = this.next++; + this.pending.set(id, callback); + return id as unknown as ReturnType; + } + clearTimeout(handle: ReturnType): 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({ + 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 { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((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 }> = []; + 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; + 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; + 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; + if (String(input).endsWith("/join")) { + joinRequestId = String(body.requestId); + return new Promise((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; + 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 }> = []; + const fetcher: typeof fetch = async (input, init = {}) => { + const body = JSON.parse(String(init.body)) as Record; + 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"); + }); +}); diff --git a/src/test/screenShareSignaling.test.ts b/src/test/screenShareSignaling.test.ts new file mode 100644 index 0000000..1d9a4c0 --- /dev/null +++ b/src/test/screenShareSignaling.test.ts @@ -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); + }); +});