diff --git a/deploy/STATIC.md b/deploy/STATIC.md index a2e8396..3f4be9b 100644 --- a/deploy/STATIC.md +++ b/deploy/STATIC.md @@ -32,6 +32,9 @@ tera.example.com { file_server header { Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" + # Screen sharing remains a per-action browser prompt. Webcam capture stays + # disabled until that separate feature is deliberately deployed. + Permissions-Policy "display-capture=(self), camera=(), microphone=(), geolocation=(), payment=(), usb=()" } } ``` diff --git a/deploy/coturn/README.md b/deploy/coturn/README.md new file mode 100644 index 0000000..db86293 --- /dev/null +++ b/deploy/coturn/README.md @@ -0,0 +1,227 @@ +# TURN for office screen sharing + +This directory is an **operator-reviewed template**, not an installer. Nothing +here changes DNS, Caddy, systemd, UFW or Oracle Cloud. Those are public routing +changes and require explicit operator approval on the deployment host. + +Tera's browser peers normally connect directly. Coturn is the fallback for +symmetric NATs and restrictive networks; it relays encrypted WebRTC packets and +never receives application signaling, screen URLs or recordings. The signaling +service remains the separate loopback API described in `server/README.md`. + +> **Mandatory public-enablement gate:** do not expose the TURN listeners or set +> `TERA_ICE_URLS` / `TERA_TURN_SHARED_SECRET` in production until the deployed +> ICE endpoint requires an active, unexpired screen-signaling grant for the exact +> office/screen binding and authenticates that grant to the same signed-in +> subject. The current endpoint checks only global member authentication, which +> would let any signed-in member mint a general-purpose Internet relay credential. +> It is intentionally not approved for broad public TURN enablement. + +The production host audited on 2026-08-11 has private VNIC `10.0.0.2`, public +IPv4 `170.9.14.61`, and an existing `turn.lumbridgecorp.com` A record. Reconfirm +all three immediately before deployment. Do not copy those addresses into a +different installation merely because they appear here. + +## 1. Review the network boundary + +The intended public listeners are: + +| Transport | Port | Purpose | +| --- | ---: | --- | +| UDP | 3478 | primary STUN/TURN listener | +| TCP | 3478 | TURN fallback | +| TCP/TLS | 5349 | TURN/TLS fallback | +| UDP | 52000–53023 | dedicated relay allocations | + +Open the same ingress in **both** UFW and the instance's OCI security list or +NSG. The OCI console is authoritative; a host firewall rule does not prove the +cloud edge permits traffic. Relay UDP must accept arbitrary Internet peers, not +only the browser that created an allocation. Outbound traffic remains allowed. + +Example commands to review, not paste blindly: + +```bash +dig +short A turn.lumbridgecorp.com +ip -br address +curl -4 https://api.ipify.org +sudo ufw status verbose + +# Only after the operator approves these public ingress changes: +sudo ufw allow 3478/udp comment 'Tera TURN UDP' +sudo ufw allow 3478/tcp comment 'Tera TURN TCP' +sudo ufw allow 5349/tcp comment 'Tera TURN TLS' +sudo ufw allow 52000:53023/udp comment 'Tera TURN relay' +``` + +Add equivalent stateful OCI ingress rules. Do not open UDP 5349: this template +disables DTLS. Do not reuse UDP 50000–50200; stopped LiveKit deployments on the +audited host reserve that range and could otherwise collide if restarted. + +The public/private NAT must preserve relay port numbers. This is why the config +uses `external-ip=PUBLIC/PRIVATE`; a relay candidate advertising `10.0.0.2` or a +different public port is a failed deployment. + +## 2. Install and render the config + +Prefer Ubuntu's native coturn package and unit. Docker adds no isolation benefit +to a service that needs a large host UDP range, and bridged port translation is +an extra failure mode. Inspect the package before enabling it: + +```bash +apt-cache policy coturn +systemctl cat coturn.service +getent passwd turnserver +getent group turnserver +``` + +Some distro packages also ship `/etc/default/coturn` with an explicit enable +switch. Inspect it and the unit's conditions; set `TURNSERVER_ENABLED=1` only as +part of the reviewed enablement, never by assuming a successful `systemctl` +command means the daemon actually bound its sockets. + +Generate one 256-bit hexadecimal secret without putting its value in shell +history: + +```bash +umask 077 +TURN_SECRET="$(openssl rand -hex 32)" +test "${#TURN_SECRET}" -eq 64 +``` + +Render `turnserver.conf.example` to a root-owned `/etc/turnserver.conf`, replacing +the two IP placeholders and the secret placeholder. The installed file should +be `root:turnserver` mode `0640`. Put the **same raw secret** in the Tera API's +root-owned environment file: + +```ini +TERA_TURN_SHARED_SECRET= +TERA_ICE_URLS=stun:turn.lumbridgecorp.com:3478,turn:turn.lumbridgecorp.com:3478?transport=udp,turn:turn.lumbridgecorp.com:3478?transport=tcp,turns:turn.lumbridgecorp.com:5349?transport=tcp +TERA_TURN_CREDENTIAL_TTL=300 +``` + +The audited service reads `/etc/tera-api.env`; the repository unit reads +`/etc/tera/tera.env`. Use the path its installed unit actually declares. Never +commit either rendered file, print the secret, put it in a URL, or pass it as a +process argument. Clear the shell variable after both files are installed: + +```bash +unset TURN_SECRET +``` + +The current authenticated `POST /api/v1/media/ice` endpoint validates only the +signed-in caller and returns a five-minute username/password generated with +coturn's REST scheme. That is insufficient authorization for a public relay. +Before enabling the environment above, the deployed request must also carry an +active screen-signaling credential and exact binding in its POST body. The server +must validate its token hash, subject, role, binding, lease and revocation state, +then rate-limit issuance by both subject and trusted client IP. The presenter or +viewer must join signaling before requesting ICE configuration. GET/query-string +credentials remain forbidden. + +An accepted response uses: + +- username: `:` +- credential: Base64 HMAC-SHA1 of that username using `TERA_TURN_SHARED_SECRET` +- response: `Cache-Control: private, no-store` + +The shared secret never leaves the server. Expiry prevents new allocations and +refreshes; it cannot instantly terminate an allocation that already exists. + +Install `tera-coturn-preflight` as `/usr/local/libexec/tera-coturn-preflight` +mode `0755`, and install the reviewed systemd drop-in only after confirming the +distro unit name. The preflight deliberately refuses unresolved placeholders, +short/non-hex secrets, and missing certificates. + +## 3. Issue and renew the TLS certificate + +Do not point coturn into Caddy's private certificate store. It is owned by the +Caddy account, and Caddy renewal does not provide coturn a reliable reload hook. + +1. Create `/var/www/turn-acme` root-owned and readable by Caddy. +2. Review `turn-acme.Caddyfile.example`, replace its private IP, import it, run + `caddy validate`, and reload Caddy. +3. Verify a test file under `/.well-known/acme-challenge/` is reachable over + public port 80. +4. Use Certbot's webroot mode for `turn.lumbridgecorp.com`. +5. Install `certbot-deploy-hook` under `/etc/letsencrypt/renewal-hooks/deploy/` + mode `0755`. +6. Run the hook once with `RENEWED_LINEAGE` set to the issued lineage, then + verify ownership and certificate names without printing the private key. + +Example issuance, after review: + +```bash +sudo certbot certonly --webroot -w /var/www/turn-acme \ + -d turn.lumbridgecorp.com +sudo env RENEWED_LINEAGE=/etc/letsencrypt/live/turn.lumbridgecorp.com \ + /etc/letsencrypt/renewal-hooks/deploy/certbot-deploy-hook +sudo openssl x509 -in /etc/coturn/certs/turn.fullchain.pem \ + -noout -subject -issuer -dates -ext subjectAltName +``` + +`try-reload-or-restart` may restart coturn when the package unit has no reload +action, interrupting allocations. Confirm the installed unit's behavior and +schedule renewal accordingly. Test renewal with `certbot renew --dry-run` before +calling the certificate path production-ready. + +## 4. Validate before enabling + +First prove the mandatory application gate: a signed-in member with no active +signaling grant, and a caller using a stopped, revoked, expired, wrong-subject or +wrong-binding grant, must all receive no TURN credential. Only then configure +the shared secret/ICE URLs and approve public firewall changes. Keep the TURN +ports closed and both Tera TURN environment values unset if any case fails. + +Run the repository preflight against a staged rendered config, then use coturn's +installed version/config inspection facilities. Do not start the daemon merely +to discover an unresolved placeholder on a public interface. + +```bash +sudo TURN_CONFIG=/etc/turnserver.conf \ + /usr/local/libexec/tera-coturn-preflight +turnserver --version +sudo systemctl daemon-reload +sudo systemctl start coturn.service # operator-approved change +sudo systemctl --no-pager --full status coturn.service +sudo journalctl -u coturn.service --since -10m --no-pager +sudo ss -lntup | grep -E ':3478|:5349' +``` + +From a machine outside OCI: + +```bash +dig +short A turn.lumbridgecorp.com +openssl s_client -connect turn.lumbridgecorp.com:5349 \ + -servername turn.lumbridgecorp.com /dev/null 2>&1 || exit 0 +getent group turnserver >/dev/null || { + echo "coturn certificate hook: turnserver group does not exist" >&2 + exit 1 +} + +install -d -o root -g turnserver -m 0750 /etc/coturn/certs +install -o root -g turnserver -m 0644 "$RENEWED_LINEAGE/fullchain.pem" /etc/coturn/certs/turn.fullchain.pem +install -o root -g turnserver -m 0640 "$RENEWED_LINEAGE/privkey.pem" /etc/coturn/certs/turn.privkey.pem +systemctl try-reload-or-restart coturn.service diff --git a/deploy/coturn/systemd-override.conf.example b/deploy/coturn/systemd-override.conf.example new file mode 100644 index 0000000..c8ad555 --- /dev/null +++ b/deploy/coturn/systemd-override.conf.example @@ -0,0 +1,7 @@ +# Install as /etc/systemd/system/coturn.service.d/tera.conf after confirming the +# distro package calls its unit coturn.service and runs as user/group turnserver. +[Service] +ExecStartPre=/usr/local/libexec/tera-coturn-preflight +LimitNOFILE=65536 +Restart=on-failure +RestartSec=3s diff --git a/deploy/coturn/tera-coturn-preflight b/deploy/coturn/tera-coturn-preflight new file mode 100755 index 0000000..818d1b6 --- /dev/null +++ b/deploy/coturn/tera-coturn-preflight @@ -0,0 +1,29 @@ +#!/bin/sh +# Fail a coturn start before an example placeholder becomes a public credential. +set -eu + +config=${TURN_CONFIG:-/etc/turnserver.conf} + +test -r "$config" || { echo "coturn preflight: cannot read $config" >&2; exit 1; } +if grep -q 'REQUIRED_' "$config"; then + echo "coturn preflight: unresolved REQUIRED_ placeholder in $config" >&2 + exit 1 +fi + +secret=$(sed -n 's/^static-auth-secret=//p' "$config") +case "$secret" in + *[!0-9A-Fa-f]*|'') + echo "coturn preflight: static-auth-secret must be hexadecimal" >&2 + exit 1 + ;; +esac +test "${#secret}" -ge 64 || { + echo "coturn preflight: static-auth-secret must contain at least 64 hex characters" >&2 + exit 1 +} + +for path in /etc/coturn/certs/turn.fullchain.pem /etc/coturn/certs/turn.privkey.pem; do + test -r "$path" || { echo "coturn preflight: cannot read $path" >&2; exit 1; } +done + +exit 0 diff --git a/deploy/coturn/turn-acme.Caddyfile.example b/deploy/coturn/turn-acme.Caddyfile.example new file mode 100644 index 0000000..4a6dd2a --- /dev/null +++ b/deploy/coturn/turn-acme.Caddyfile.example @@ -0,0 +1,12 @@ +# HTTP-01 only. Review, place beside the real Caddyfile, and import it there. +# Caddy does not proxy TURN; coturn owns 3478/5349 and its UDP relay range. +http://turn.lumbridgecorp.com { + bind REQUIRED_PRIVATE_IPV4 + + handle /.well-known/acme-challenge/* { + root * /var/www/turn-acme + file_server + } + + respond 404 +} diff --git a/deploy/coturn/turnserver.conf.example b/deploy/coturn/turnserver.conf.example new file mode 100644 index 0000000..8c5c860 --- /dev/null +++ b/deploy/coturn/turnserver.conf.example @@ -0,0 +1,69 @@ +# Tera coturn template. Copy to /etc/turnserver.conf only after replacing every +# REQUIRED_* value and completing the operator review in README.md. + +listening-port=3478 +tls-listening-port=5349 + +# Oracle assigns the public address through one-to-one NAT. Coturn must bind the +# VNIC address while advertising the port-preserving public/private mapping. +listening-ip=REQUIRED_PRIVATE_IPV4 +relay-ip=REQUIRED_PRIVATE_IPV4 +external-ip=REQUIRED_PUBLIC_IPV4/REQUIRED_PRIVATE_IPV4 + +realm=turn.lumbridgecorp.com +server-name=turn.lumbridgecorp.com +fingerprint + +# Tera's authenticated ICE endpoint and coturn hold the same random secret. +# Do not enable both lt-cred-mech and use-auth-secret; the latter is coturn's +# time-limited TURN REST authentication mode. +use-auth-secret +static-auth-secret=REQUIRED_RANDOM_64_HEX_SECRET +stale-nonce + +cert=/etc/coturn/certs/turn.fullchain.pem +pkey=/etc/coturn/certs/turn.privkey.pem + +# Dedicated to Tera. Do not overlap the retired LiveKit 50000-50200 range. +min-port=52000 +max-port=53023 + +# One credential may briefly own several allocations during ICE restart. The +# total stays below the 1,024-port relay range; tune only from observed usage. +user-quota=4 +total-quota=900 +max-bps=2000000 +bps-capacity=500000000 + +# Browser media uses UDP relay endpoints even when its connection to coturn is +# TCP/TLS. Disabling RFC 6062 TCP peer relays reduces proxy-abuse surface. +no-tcp-relay +no-dtls +no-multicast-peers +no-cli +no-software-attribute + +# Never turn the public relay into a route to local, cloud metadata, Docker, +# Tailscale/CGNAT, documentation, multicast, or reserved networks. Coturn uses +# inclusive address ranges here, not CIDR notation. +denied-peer-ip=0.0.0.0-0.255.255.255 +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=100.64.0.0-100.127.255.255 +denied-peer-ip=127.0.0.0-127.255.255.255 +denied-peer-ip=169.254.0.0-169.254.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 +denied-peer-ip=192.0.0.0-192.0.0.255 +denied-peer-ip=192.0.2.0-192.0.2.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +denied-peer-ip=198.18.0.0-198.19.255.255 +denied-peer-ip=198.51.100.0-198.51.100.255 +denied-peer-ip=203.0.113.0-203.0.113.255 +denied-peer-ip=224.0.0.0-255.255.255.255 +denied-peer-ip=::1-::1 +denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff +denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff +denied-peer-ip=ff00::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff + +# Binding-request logging is intentionally left disabled. Keep logs in the +# journal/syslog and never add credentials or full SDP to application logs. +syslog diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index c5d07c9..5d29566 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -42,6 +42,9 @@ services: TERA_AUTH_ENTRY_URL: "${TERA_AUTH_ENTRY_URL:-}" TERA_AUTH_REVALIDATE_URL: "${TERA_AUTH_REVALIDATE_URL:-}" TERA_AUTH_JWT_SECRET: "${TERA_AUTH_JWT_SECRET:-}" + TERA_ICE_URLS: "${TERA_ICE_URLS:-}" + TERA_TURN_SHARED_SECRET: "${TERA_TURN_SHARED_SECRET:-}" + TERA_TURN_CREDENTIAL_TTL: "${TERA_TURN_CREDENTIAL_TTL:-}" # Offices and marker snapshots are files. Mount them read-only where you # keep them; the container writes nothing, ever. # diff --git a/server/README.md b/server/README.md index ee895e7..a01d8af 100644 --- a/server/README.md +++ b/server/README.md @@ -35,6 +35,7 @@ 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/ice` | — | strict ICE configuration request | **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** | @@ -42,7 +43,8 @@ without breaking the typecheck, which is the wrong order to find out. 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 +`src/media/signalingTypes.ts` and ICE configuration in `src/media/iceTypes.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 @@ -111,6 +113,23 @@ 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. +TURN is optional and fail-closed. When both `TERA_ICE_URLS` and a strong +`TERA_TURN_SHARED_SECRET` are configured, an authenticated caller may POST an +exact `IceConfigRequest` to `/api/v1/media/ice`. The service returns only +short-lived coturn REST credentials: an expiration plus a random opaque nonce +as username, and its HMAC-SHA1 password. An auth subject, email, profile id and +screen id never enter the TURN username. The shared secret stays server-only. +Partial or malformed configuration disables issuance and returns a typed 503; +the hosted browser path reports relay unavailability rather than promising a +connection that will fail across NAT. Caller-owned local screen preview remains +independent of the relay. + +Allowed ICE URLs are deliberately narrow: `stun:`, `stuns:`, `turn:` and +`turns:` with a host and optional port. TURN may use only the standard exact +`?transport=udp` or `?transport=tcp` selector; userinfo, credential query +parameters and arbitrary URL syntax are rejected. Issuance is rate-limited by +the trusted proxy client address and every response remains `private, no-store`. + ## Regions **A caller's coordinate is never forwarded upstream. It only selects among the @@ -304,6 +323,11 @@ Setting it is a licence claim you are making on the record. | `TERA_AUTH_JWT_VERIFY` | `hs256` | Set to `jwks` for asymmetric verification. | | `TERA_AUTH_JWKS_URL` | *(empty)* | | | `TERA_AUTH_JWT_ISSUER` / `_AUDIENCE` | *(empty)* | Checked when set. | +| `TERA_ICE_URLS` | *(empty)* | Comma-separated credential-free STUN/TURN URLs. Must include `turn:` or `turns:` to enable issuance. | +| `TERA_TURN_SHARED_SECRET` | *(empty)* | Server-only coturn `use-auth-secret` value, 32–4096 bytes. Never expose this in the static build. | +| `TERA_TURN_CREDENTIAL_TTL` | `300` | Credential lifetime in seconds, bounded to 60–3600. | +| `TERA_ICE_RATE_ATTEMPTS` | `30` | Maximum grants per trusted client address in one rate window. | +| `TERA_ICE_RATE_WINDOW` | `60` | Rate window in seconds, bounded to 1–3600. | A self-hoster gets `none`, an open office, and never creates an account anywhere. `sso` is what Lumbridge's own deployment uses: this world holds **no diff --git a/server/src/config.ts b/server/src/config.ts index 1a7618a..4687e3b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,6 +20,7 @@ import { readFileSync } from "node:fs"; import { parseScryptHash, type ScryptHash } from "./auth/password.ts"; import { loadRegions, type RegionSet } from "./regions.ts"; +import { isSafeIceUrl } from "../../src/media/iceValidation.ts"; import type { AuthMode, FlightsSourceId, @@ -136,6 +137,16 @@ export interface AuthConfig { admins: AdminGrant; } +export interface IceConfig { + configured: boolean; + urls: string[]; + /** Server-only coturn REST shared secret. Never serialize this config. */ + sharedSecret: string; + credentialTtlSeconds: number; + rateAttempts: number; + rateWindowSeconds: number; +} + export interface Config { host: string; port: number; @@ -168,6 +179,7 @@ export interface Config { */ presence: { dir: string }; auth: AuthConfig; + ice: IceConfig; /** One sentence per demotion. Empty on a fully-configured box. */ degraded: string[]; } @@ -181,6 +193,7 @@ export function loadConfig(env: Env = process.env): Config { const flights = loadFlights(env, degraded); const satellites = loadSatellites(env, degraded); const auth = loadAuth(env, degraded); + const ice = loadIce(env, degraded); // After auth, because a marker feed with nobody able to sign in is worth a // sentence and the sentence is only true once `mode` has finished demoting. const markers = loadMarkers(env, auth.mode, degraded); @@ -212,12 +225,59 @@ export function loadConfig(env: Env = process.env): Config { offices: { dir: str(env, "TERA_OFFICES_DIR", "") }, presence: { dir: str(env, "TERA_PRESENCE_DIR", "") }, auth, + ice, degraded, }; } // ---- Sections ------------------------------------------------------------- +function loadIce(env: Env, degraded: string[]): IceConfig { + const rawUrls = env.TERA_ICE_URLS; + const rawSecret = env.TERA_TURN_SHARED_SECRET; + const absent = (rawUrls === undefined || rawUrls.trim() === "") && + (rawSecret === undefined || rawSecret === ""); + const disabled: IceConfig = { + configured: false, + urls: [], + sharedSecret: "", + credentialTtlSeconds: 300, + rateAttempts: 30, + rateWindowSeconds: 60, + }; + if (absent) return disabled; + + const urls = (rawUrls ?? "").split(",").map((value) => value.trim()).filter(Boolean); + const secret = rawSecret ?? ""; + const ttl = strictInteger(env.TERA_TURN_CREDENTIAL_TTL, 300, 60, 3_600); + const attempts = strictInteger(env.TERA_ICE_RATE_ATTEMPTS, 30, 1, 300); + const windowSeconds = strictInteger(env.TERA_ICE_RATE_WINDOW, 60, 1, 3_600); + const urlsValid = urls.length > 0 && urls.length <= 8 && new Set(urls).size === urls.length && + urls.every(isSafeIceUrl) && urls.some((url) => url.startsWith("turn:") || url.startsWith("turns:")); + const secretValid = secret.length >= 32 && secret.length <= 4_096 && secret === secret.trim() && + !/[\u0000-\u001f\u007f]/.test(secret); + + if (!urlsValid || !secretValid || ttl === null || attempts === null || windowSeconds === null) { + degraded.push("ICE credential service is disabled because its TURN configuration is incomplete or invalid."); + return disabled; + } + return { + configured: true, + urls, + sharedSecret: secret, + credentialTtlSeconds: ttl, + rateAttempts: attempts, + rateWindowSeconds: windowSeconds, + }; +} + +function strictInteger(raw: string | undefined, fallback: number, minimum: number, maximum: number): number | null { + if (raw === undefined || raw === "") return fallback; + if (!/^[0-9]+$/.test(raw)) return null; + const parsed = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : null; +} + const WEATHER_SOURCES: WeatherSourceId[] = ["none", "nws", "metno", "openmeteo"]; function loadWeather(env: Env, degraded: string[]): WeatherConfig { diff --git a/server/src/media/ice.ts b/server/src/media/ice.ts new file mode 100644 index 0000000..ab36e90 --- /dev/null +++ b/server/src/media/ice.ts @@ -0,0 +1,100 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { + ICE_CONFIG_PROTOCOL_VERSION, + type IceConfigGrant, + type IceConfigRequest, + type IceConfigUnavailable, + type StunIceServer, + type TurnIceServer, +} from "../../../src/media/iceTypes.ts"; +import type { IceConfig } from "../config.ts"; + +export type IceCredentialResult = + | { ok: true; value: IceConfigGrant } + | { ok: false; code: "unavailable" | "rate-limited"; value: IceConfigUnavailable }; + +export interface IceCredentialProvider { + issue(request: IceConfigRequest, rateKey: string): IceCredentialResult; +} + +export interface IceCredentialProviderOptions { + now?: () => number; + nonce?: () => string; +} + +interface RateEntry { startedAtMs: number; count: number } +const MAX_RATE_KEYS = 4_096; + +export function createIceCredentialProvider( + config: IceConfig, + options: IceCredentialProviderOptions = {}, +): IceCredentialProvider { + const now = options.now ?? Date.now; + const nonce = options.nonce ?? (() => randomBytes(18).toString("base64url")); + const rates = new Map(); + const windowMs = config.rateWindowSeconds * 1_000; + + return { + issue(request, rateKey) { + const at = now(); + if (!config.configured) return unavailable(request.requestId, windowMs, "unavailable"); + let entry = rates.get(rateKey); + if (!entry && rates.size >= MAX_RATE_KEYS) { + for (const [key, value] of rates) { + if (at - value.startedAtMs >= windowMs) rates.delete(key); + } + if (rates.size >= MAX_RATE_KEYS) { + return unavailable(request.requestId, windowMs, "rate-limited"); + } + entry = rates.get(rateKey); + } + if (!entry || at - entry.startedAtMs >= windowMs) { + rates.set(rateKey, { startedAtMs: at, count: 1 }); + } else { + entry.count += 1; + if (entry.count > config.rateAttempts) { + return unavailable(request.requestId, Math.max(1_000, windowMs - (at - entry.startedAtMs)), "rate-limited"); + } + } + + const expiresAtSeconds = Math.floor(at / 1_000) + config.credentialTtlSeconds; + // Coturn REST convention: expiration timestamp, colon, opaque username. + // No auth subject, email, profile id, IP address, or stable browser id. + const username = `${expiresAtSeconds}:${nonce()}`; + const credential = createHmac("sha1", config.sharedSecret).update(username).digest("base64"); + const stunUrls = config.urls.filter((url) => url.startsWith("stun:") || url.startsWith("stuns:")); + const turnUrls = config.urls.filter((url) => url.startsWith("turn:") || url.startsWith("turns:")); + const iceServers: Array = []; + if (stunUrls.length > 0) iceServers.push({ urls: stunUrls }); + iceServers.push({ urls: turnUrls, username, credential, credentialType: "password" }); + return { + ok: true, + value: { + type: "ice-config-grant", + protocolVersion: ICE_CONFIG_PROTOCOL_VERSION, + requestId: request.requestId, + issuedAtMs: at, + expiresAtMs: expiresAtSeconds * 1_000, + iceServers, + }, + }; + }, + }; +} + +function unavailable( + requestId: string, + retryAfterMs: number, + code: "unavailable" | "rate-limited", +): IceCredentialResult { + return { + ok: false, + code, + value: { + type: "ice-config-unavailable", + protocolVersion: ICE_CONFIG_PROTOCOL_VERSION, + requestId, + retryAfterMs: Math.min(3_600_000, Math.max(1_000, Math.ceil(retryAfterMs))), + }, + }; +} diff --git a/server/src/media/index.ts b/server/src/media/index.ts index e1ab384..a00b8cd 100644 --- a/server/src/media/index.ts +++ b/server/src/media/index.ts @@ -6,3 +6,9 @@ export { type MediaSignalServiceOptions, } from "./service.ts"; export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts"; +export { + createIceCredentialProvider, + type IceCredentialProvider, + type IceCredentialProviderOptions, + type IceCredentialResult, +} from "./ice.ts"; diff --git a/server/src/media/service.ts b/server/src/media/service.ts index 5bbe915..0930348 100644 --- a/server/src/media/service.ts +++ b/server/src/media/service.ts @@ -63,6 +63,7 @@ interface Participant { rateStartedAt: number; rateCount: number; queue: ScreenSharePeerMessage[]; + queueOverflowed: boolean; listeners: Set<(message: ScreenSharePeerMessage) => void>; } @@ -121,6 +122,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} // snapshot, so dropping the oldest relay is safer than letting a sender // evict another participant by filling its queue. participant.queue.shift(); + participant.queueOverflowed = true; } participant.queue.push(message); } @@ -207,10 +209,9 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} 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) { + if (!clientSequenceHasSuccessor(sequence) || sequence <= participant.lastClientSequence) { return { ok: false, code: "conflict", message: "Media client sequence is not monotonic." }; } - participant.lastClientSequence = sequence; return { ok: true, value: null }; } @@ -220,10 +221,14 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} 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(), + queueOverflowed: false, } }; } function join(request: ScreenShareCreateRequest | ScreenShareJoinRequest, subject: string): MediaSignalResult { + if (!clientSequenceHasSuccessor(request.sequence)) { + return { ok: false, code: "invalid", message: "Media client sequence cannot advance." }; + } cleanup(); if (request.type === "screen-share-create-request") { if (sessions.size >= maximumSessions) return { ok: false, code: "capacity", message: "Media signaling is at capacity." }; @@ -263,22 +268,35 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} const found = authenticate(request.credential, subject); if (!found.ok) return found; const { session, participant } = found.value; + if (request.lastReceivedSequence > session.sequence) { + return { ok: false, code: "conflict", message: "Media server sequence is ahead of this session." }; + } const valid = validateRequest(session, participant, request.binding, request.sequence); if (!valid.ok) return valid; + participant.lastClientSequence = request.sequence; 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; + const continuous = !participant.queueOverflowed; + const queued = participant.queue.filter((message) => message.sequence > request.lastReceivedSequence); + const grantSequence = nextSequence(session); + const resumedAt = now(); + // The resume grant is written before subscribe drains this queue. Rebase + // retained messages onto fresh server sequences so a strict client cursor + // can accept them after the grant instead of treating them as stale. + participant.queue = queued.map((message) => ({ + ...message, + sequence: nextSequence(session), + timestampMs: resumedAt, + })); + participant.queueOverflowed = false; 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, + requestId: request.requestId, sequence: grantSequence, timestampMs: resumedAt, binding: session.binding, grant: grant(session, participant, token), nextClientSequence: request.sequence + 1, - participants: peerList(session, participant.id), continuous: false, + participants: peerList(session, participant.id), continuous, } }; } @@ -296,6 +314,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} 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." }; } + participant.lastClientSequence = request.sequence; 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, @@ -313,12 +332,14 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} 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." }; + participant.lastClientSequence = request.sequence; 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." }; + participant.lastClientSequence = request.sequence; destroySession(session, { type: "screen-share-revoked", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION, sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding, @@ -333,6 +354,7 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {} 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." }; + participant.lastClientSequence = request.sequence; removeParticipant(session, target, request.reason); return { ok: true, value: null }; } @@ -400,6 +422,9 @@ 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 clientSequenceHasSuccessor(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0 && value < Number.MAX_SAFE_INTEGER; +} 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 index e307971..4af1c6d 100644 --- a/server/src/routes/media.ts +++ b/server/src/routes/media.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import { parseScreenShareClientMessage } from "../../../src/media/signalingValidation.ts"; +import { parseIceConfigRequest } from "../../../src/media/iceValidation.ts"; import type { ScreenShareBinding, ScreenShareCreateRequest, @@ -43,6 +44,23 @@ function parse(body: unknown) { } export function registerMedia(app: FastifyInstance, services: Services): void { + app.post<{ Body: unknown }>("/api/v1/media/ice", { bodyLimit: 1_024 }, async (req, reply) => { + const viewer = await services.auth.resolve(req); + if (!viewer.authenticated) { + return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED); + } + const parsed = parseIceConfigRequest(req.body); + if (!parsed.ok) return reply.code(400).send({ error: "invalid", message: "ICE configuration request is invalid." }); + const issued = services.ice.issue(parsed.value, req.ip); + if (!issued.ok) { + const statusCode = issued.code === "rate-limited" ? 429 : 503; + return reply.code(statusCode) + .header("retry-after", String(Math.ceil(issued.value.retryAfterMs / 1_000))) + .send(issued.value); + } + return reply.code(200).header("pragma", "no-cache").send(issued.value); + }); + 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) { diff --git a/server/src/services.ts b/server/src/services.ts index 5957613..bccc0ac 100644 --- a/server/src/services.ts +++ b/server/src/services.ts @@ -10,7 +10,12 @@ 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 { + createIceCredentialProvider, + createMediaSignalService, + type IceCredentialProvider, + 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"; @@ -25,6 +30,7 @@ export interface Services { satellites: SatellitesService; markers: MarkerStore; media: MediaSignalService; + ice: IceCredentialProvider; offices: OfficeStore; presence: PresenceStore; realtime: RealtimeService; @@ -46,6 +52,7 @@ export function createServices(config: Config, log: ServiceLog): Services { satellites: createSatellitesService(config, log), markers: createMarkerStore(config, log), media: createMediaSignalService(), + ice: createIceCredentialProvider(config.ice), offices: createOfficeStore(config.offices.dir), presence: createPresenceStore(config.presence.dir), realtime: createRealtimeService(), diff --git a/server/src/test/ice.test.ts b/server/src/test/ice.test.ts new file mode 100644 index 0000000..1f6725f --- /dev/null +++ b/server/src/test/ice.test.ts @@ -0,0 +1,137 @@ +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, type IceConfig } from "../config.ts"; +import { createIceCredentialProvider } from "../media/index.ts"; +import { parseIceConfigResponse } from "../../../src/media/iceValidation.ts"; +import type { IceConfigGrant } from "../../../src/media/iceTypes.ts"; + +const SHARED_SECRET = "turn-shared-secret-with-at-least-thirty-two-bytes"; +const JWT_SECRET = "ice-route-jwt-secret-that-is-long-enough"; + +const configured = (overrides: Partial = {}): IceConfig => ({ + configured: true, + urls: ["stun:relay.example.test:3478", "turn:relay.example.test:3478", "turns:relay.example.test:5349"], + sharedSecret: SHARED_SECRET, + credentialTtlSeconds: 600, + rateAttempts: 2, + rateWindowSeconds: 60, + ...overrides, +}); + +const request = { type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1" } as const; + +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", JWT_SECRET).update(signed).digest("base64url")}` }; +} + +describe("coturn REST ICE credential provider", () => { + it("uses an opaque nonce and the standard expiration:HMAC-SHA1 credential", () => { + const provider = createIceCredentialProvider(configured(), { + now: () => 1_700_000_000_500, + nonce: () => "opaque_random_nonce_1234", + }); + const result = provider.issue(request, "rate-key"); + assert.equal(result.ok, true); + if (!result.ok) return; + const turn = result.value.iceServers.find((server) => "username" in server); + assert.ok(turn && "username" in turn); + if (!turn || !("username" in turn)) return; + assert.equal(turn.username, "1700000600:opaque_random_nonce_1234"); + assert.equal(turn.credential, createHmac("sha1", SHARED_SECRET).update(turn.username).digest("base64")); + assert.equal(turn.username.includes("auth-subject"), false); + assert.equal(JSON.stringify(result.value).includes(SHARED_SECRET), false); + assert.equal(parseIceConfigResponse(result.value).ok, true); + }); + + it("fails closed when unconfigured and rate bounds issuance", () => { + const unavailable = createIceCredentialProvider(configured({ configured: false, sharedSecret: "", urls: [] })); + const closed = unavailable.issue(request, "caller"); + assert.equal(closed.ok, false); + if (!closed.ok) assert.equal(closed.code, "unavailable"); + const limited = createIceCredentialProvider(configured({ rateAttempts: 1 }), { + now: () => 10_000, nonce: () => "opaque_random_nonce_1234", + }); + assert.equal(limited.issue(request, "same-ip").ok, true); + const second = limited.issue(request, "same-ip"); + assert.equal(second.ok, false); + if (!second.ok) assert.equal(second.code, "rate-limited"); + assert.equal(limited.issue(request, "other-ip").ok, true); + }); + + it("keeps the client-rate table bounded under address churn", () => { + let time = 10_000; + const provider = createIceCredentialProvider(configured({ rateAttempts: 2 }), { + now: () => time, nonce: () => "opaque_random_nonce_1234", + }); + for (let index = 0; index < 4_096; index += 1) { + assert.equal(provider.issue(request, `client-${index}`).ok, true); + } + const full = provider.issue(request, "client-over-cap"); + assert.equal(full.ok, false); + if (!full.ok) assert.equal(full.code, "rate-limited"); + time += 60_000; + assert.equal(provider.issue(request, "client-after-window").ok, true); + }); + + it("strictly disables partial or malformed environment configuration", () => { + assert.equal(loadConfig({ TERA_ICE_URLS: "turn:relay.example.test:3478" }).ice.configured, false); + assert.equal(loadConfig({ TERA_TURN_SHARED_SECRET: SHARED_SECRET }).ice.configured, false); + assert.equal(loadConfig({ + TERA_ICE_URLS: "turn:user@relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET, + }).ice.configured, false); + assert.equal(loadConfig({ + TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET, + TERA_TURN_CREDENTIAL_TTL: "60.5", + }).ice.configured, false); + const valid = loadConfig({ + TERA_ICE_URLS: "stun:relay.example.test:3478, turns:relay.example.test:5349", + TERA_TURN_SHARED_SECRET: SHARED_SECRET, + }); + assert.equal(valid.ice.configured, true); + assert.equal(valid.degraded.some((line) => line.includes(SHARED_SECRET)), false); + }); +}); + +describe("authenticated ICE route", () => { + it("returns private ephemeral credentials only to authenticated POST callers", async () => { + const config = loadConfig({ + TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET, + TERA_ICE_URLS: "turn:relay.example.test:3478", TERA_TURN_SHARED_SECRET: SHARED_SECRET, + }); + config.logLevel = "silent"; + const app = buildApp(config); after(() => app.close()); + assert.equal((await app.inject({ method: "GET", url: "/api/v1/media/ice" })).statusCode, 404); + assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", payload: request })).statusCode, 401); + const malformed = await app.inject({ + method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), + payload: { ...request, profile: "do-not-send" }, + }); + assert.equal(malformed.statusCode, 400); + const response = await app.inject({ + method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: request, + }); + assert.equal(response.statusCode, 200); + assert.equal(response.headers["cache-control"], "private, no-store"); + assert.equal(response.headers.pragma, "no-cache"); + assert.equal(response.headers.location, undefined); + const body = response.json(); + assert.equal(parseIceConfigResponse(body).ok, true); + assert.equal(JSON.stringify(body).includes("auth-subject"), false); + }); + + it("returns typed unavailable without emitting credentials when TURN is absent", async () => { + const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: JWT_SECRET }); + config.logLevel = "silent"; + const app = buildApp(config); after(() => app.close()); + const response = await app.inject({ + method: "POST", url: "/api/v1/media/ice", headers: bearer("auth-subject"), payload: request, + }); + assert.equal(response.statusCode, 503); + assert.equal(response.json().type, "ice-config-unavailable"); + assert.equal(JSON.stringify(response.json()).includes("credential"), false); + }); +}); diff --git a/server/src/test/media.test.ts b/server/src/test/media.test.ts index 0dc0a2d..0dffca8 100644 --- a/server/src/test/media.test.ts +++ b/server/src/test/media.test.ts @@ -76,6 +76,87 @@ describe("office media signaling service", () => { assert.equal(service.sessionCount(), 0); }); + it("delivers a queued offer after resume with a sequence newer than the grant", () => { + const service = createMediaSignalService(); + const { presenter, viewer } = grants(service); + const offered = service.signal({ + type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_001, + binding: BINDING, credential: presenter.grant.credential, + targetParticipantId: viewer.grant.credential.participantId, + signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0\r\na=ice-options:trickle" }, + }, "presenter-subject"); + assert.equal(offered.ok, true); + + const resumed = service.resume({ + type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-viewer-resume", + sequence: 2, timestampMs: 1_002, binding: BINDING, + credential: viewer.grant.credential, lastReceivedSequence: viewer.sequence, + }, "viewer-subject"); + assert.equal(resumed.ok, true); + if (!resumed.ok) return; + + const delivered: Array<{ type: string; sequence: number }> = []; + const subscribed = service.subscribe( + resumed.value.grant.credential, + "viewer-subject", + (message) => delivered.push({ type: message.type, sequence: message.sequence }), + ); + assert.equal(subscribed.ok, true); + const relay = delivered.find((message) => message.type === "screen-share-signal-relay"); + assert.ok(relay, "queued offer relay is delivered after token rotation"); + assert.ok(relay.sequence > resumed.value.sequence, "relay follows the resume grant cursor"); + assert.ok(delivered.every((message) => message.sequence > resumed.value.sequence)); + }); + + it("rejects exhausted client sequences without orphaning sessions or grants", () => { + const service = createMediaSignalService(); + const exhaustedCreate = service.join(create(Number.MAX_SAFE_INTEGER), "presenter-subject"); + assert.equal(exhaustedCreate.ok, false); + assert.equal(service.sessionCount(), 0, "invalid create does not leave an unreachable session"); + + const presenter = service.join(create(), "presenter-subject"); + assert.equal(presenter.ok, true); + const exhaustedJoin = service.join(join(Number.MAX_SAFE_INTEGER), "viewer-subject"); + assert.equal(exhaustedJoin.ok, false); + const viewer = service.join(join(), "viewer-subject"); + assert.equal(viewer.ok, true, "invalid join does not leave an orphan participant"); + if (!viewer.ok || viewer.value.type !== "screen-share-join-grant") return; + + const exhaustedResume = service.resume({ + type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-exhausted-resume", + sequence: Number.MAX_SAFE_INTEGER, timestampMs: 1_002, binding: BINDING, + credential: viewer.value.grant.credential, lastReceivedSequence: viewer.value.sequence, + }, "viewer-subject"); + assert.equal(exhaustedResume.ok, false); + assert.equal(service.revalidate(viewer.value.grant.credential, "viewer-subject").ok, true, + "rejected resume does not rotate away the usable grant"); + const correctedResume = service.resume({ + type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-corrected-resume", + sequence: 2, timestampMs: 1_003, binding: BINDING, + credential: viewer.value.grant.credential, lastReceivedSequence: viewer.value.sequence, + }, "viewer-subject"); + assert.equal(correctedResume.ok, true, "rejected resume does not consume its client sequence"); + }); + + it("commits a signaling sequence only after the request is authorized", () => { + const service = createMediaSignalService(); + const { presenter, viewer } = grants(service); + const received: string[] = []; + service.subscribe(viewer.grant.credential, "viewer-subject", (message) => received.push(message.type)); + const offer: ScreenShareSignalRequest = { + type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_002, + binding: BINDING, credential: presenter.grant.credential, + targetParticipantId: "missing-opaque-participant", + signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0" }, + }; + assert.equal(service.signal(offer, "presenter-subject").ok, false); + assert.equal(service.signal({ + ...offer, + targetParticipantId: viewer.grant.credential.participantId, + }, "presenter-subject").ok, true, "corrected request can reuse the rejected sequence"); + assert.deepEqual(received.filter((type) => type === "screen-share-signal-relay"), ["screen-share-signal-relay"]); + }); + it("bounds the mesh and sends viewer lifecycle snapshots", () => { const service = createMediaSignalService({ maximumParticipantsPerSession: 2 }); const { presenter, viewer } = grants(service); @@ -163,4 +244,58 @@ describe("office media signaling routes", () => { assert.equal(streamed.url.includes("grantToken"), false); controller.abort(); }); + + it("streams a pre-subscription offer after a newer viewer resume grant", 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 post = async (path: string, subject: string, payload: unknown) => fetch(`${origin}${path}`, { + method: "POST", headers: { ...bearer(subject), "content-type": "application/json" }, body: JSON.stringify(payload), + }); + const presenterResponse = await post("/api/v1/media/join", "presenter", create()); + const viewerResponse = await post("/api/v1/media/join", "viewer", join()); + const presenter = await presenterResponse.json() as { + grant: { credential: ScreenShareSignalRequest["credential"] }; + }; + const viewer = await viewerResponse.json() as { + sequence: number; + grant: { credential: ScreenShareSignalRequest["credential"] }; + }; + const offered = await post("/api/v1/media/signal", "presenter", { + type: "screen-share-signal-request", protocolVersion: 1, sequence: 2, timestampMs: 1_002, + binding: BINDING, credential: presenter.grant.credential, + targetParticipantId: viewer.grant.credential.participantId, + signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0\r\na=ice-options:trickle" }, + }); + assert.equal(offered.status, 204); + + const controller = new AbortController(); + const streamed = await fetch(`${origin}/api/v1/media/events`, { + method: "POST", signal: controller.signal, + headers: { ...bearer("viewer"), "content-type": "application/json" }, + body: JSON.stringify({ + type: "screen-share-resume-request", protocolVersion: 1, requestId: "request-viewer-events", + sequence: 2, timestampMs: 1_003, binding: BINDING, + credential: viewer.grant.credential, lastReceivedSequence: viewer.sequence, + }), + }); + assert.equal(streamed.status, 200); + const reader = streamed.body?.getReader(); + assert.ok(reader); + let text = ""; + for (let reads = 0; reads < 3 && !text.includes("screen-share-signal-relay"); reads += 1) { + const chunk = await reader.read(); + if (chunk.done) break; + text += new TextDecoder().decode(chunk.value); + } + controller.abort(); + const messages = text.split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => JSON.parse(line.slice(6)) as { type: string; sequence: number }); + const grant = messages.find((message) => message.type === "screen-share-resume-grant"); + const relay = messages.find((message) => message.type === "screen-share-signal-relay"); + assert.ok(grant); + assert.ok(relay, "queued offer reaches the fetch-stream after resume"); + assert.ok(relay.sequence > grant.sequence); + }); }); diff --git a/src/main.ts b/src/main.ts index e462d85..4c087b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -89,7 +89,9 @@ import { createOfficeScreenPanel, type MediaSurfaceDescriptor, type OfficeScreenPanel, + type OfficeScreenRemoteStatus, } from "./media/index.ts"; +import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts"; /** * Three type-only imports and not one value among them, which is what keeps the * office and the instruments out of the entry chunk. @@ -454,7 +456,18 @@ let sharedScreen: { stream: MediaStream; video: HTMLVideoElement; texture: VideoTexture; + remote: RemoteOfficeMedia | null; } | null = null; +let remoteViewedScreen: { + screenId: string; + officeId: string; + video: HTMLVideoElement; + texture: VideoTexture; + remote: RemoteOfficeMedia; + bound: boolean; +} | null = null; +let remoteMediaOperation = 0; +let remoteViewerRequestScreenId: string | null = null; /** * The plan panel's other occupant. * @@ -2194,6 +2207,7 @@ async function initializeRealtimePresence(): Promise { window.addEventListener("pagehide", () => { realtimePageActive = false; + disposeOfficeScreenUi(); realtimeOperation += 1; stopRealtimeSubscription?.(); stopRealtimeSubscription = null; @@ -2266,11 +2280,206 @@ function openProfileEditor(): void { editor.open(); } +function screenBinding(surface: MediaSurfaceDescriptor) { + return { + officeId: surface.officeId, + levelId: surface.levelId, + roomId: surface.roomId, + screenId: surface.screenId, + }; +} + +function remotePanelStatus(screenId: string, status: OfficeScreenRemoteStatus): void { + officeScreenPanel?.setRemoteStatus(screenId, status); + renderOfficeBadge(); +} + +function isRemoteAuthorizationError(error: unknown): boolean { + return error instanceof Error && /\((?:401|403)\)/.test(error.message); +} + +function releaseRemoteViewer( + status: OfficeScreenRemoteStatus = "off", + action: "revoke" | "dispose" = "revoke", +): void { + const viewing = remoteViewedScreen; + const pendingScreenId = remoteViewerRequestScreenId; + remoteMediaOperation += 1; + remoteViewerRequestScreenId = null; + if (pendingScreenId && pendingScreenId !== viewing?.screenId) { + remotePanelStatus(pendingScreenId, status); + } + if (!viewing) return; + remoteViewedScreen = null; + office?.clearMediaSurface(viewing.screenId); + // `OfficeScreenPanel` owns defensive descriptor snapshots rather than a + // live view of OfficeScene. Refresh after clear just as the bind path does, + // otherwise the panel can keep saying "Media active" after remote teardown. + officeScreenPanel?.update(office?.listMediaSurfaces() ?? []); + viewing.texture.dispose(); + viewing.video.pause(); + viewing.video.srcObject = null; + remotePanelStatus(viewing.screenId, status); + const finish = action === "revoke" ? viewing.remote.revoke() : viewing.remote.dispose(); + void finish.catch(() => undefined); +} + +function syncRemoteViewerState( + viewing: NonNullable, + state: RemoteMediaState, +): void { + if (remoteViewedScreen !== viewing) return; + if (state.status === "reconnecting") remotePanelStatus(viewing.screenId, "reconnecting"); + else if (state.status === "connecting") remotePanelStatus(viewing.screenId, "connecting"); + else if (state.status === "live") remotePanelStatus(viewing.screenId, "live"); + if (state.hasRemoteVideo && !viewing.bound) { + // Playback is a consequence of the user's screen-specific opt-in click; + // the transport itself deliberately never calls `play()`. + void viewing.video.play().then(() => { + if (remoteViewedScreen !== viewing || !office || !inside) return; + viewing.bound = office.bindMediaSurface( + viewing.screenId, + { canView: true, optedIn: true }, + viewing.texture, + ); + officeScreenPanel?.update(office.listMediaSurfaces()); + remotePanelStatus(viewing.screenId, viewing.bound ? "live" : "error"); + }).catch(() => { + if (remoteViewedScreen === viewing) { + showDetail("The remote screen arrived, but this browser could not start video playback."); + releaseRemoteViewer("error", "dispose"); + } + }); + } + if (state.status === "stopped" || state.status === "revoked") { + showDetail(state.status === "revoked" ? "Remote screen access was revoked." : "The remote screen share stopped."); + releaseRemoteViewer("stopped", "dispose"); + } +} + +async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise { + if (access.subject === null || !inside || !office || office.depth !== "full") return; + releaseRemoteViewer("off"); + const operation = ++remoteMediaOperation; + remoteViewerRequestScreenId = surface.screenId; + remotePanelStatus(surface.screenId, "connecting"); + showDetail(`Connecting to ${surface.screenId}…`); + try { + const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([ + import("./media/remoteMedia.ts"), + import("./media/iceClient.ts"), + ]); + const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false; + if (operation !== remoteMediaOperation || access.subject === null || !inside || !office || + office.depth !== "full" || officeId !== surface.officeId || !optedIn) return; + const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch }); + if (operation !== remoteMediaOperation || access.subject === null || !inside || !office || + office.depth !== "full" || officeId !== surface.officeId || + !(officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false)) return; + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + video.autoplay = false; + const texture = new VideoTexture(video); + texture.colorSpace = SRGBColorSpace; + texture.generateMipmaps = false; + let viewing: NonNullable; + const remote = createRemoteOfficeMedia({ + role: "viewer", + binding: screenBinding(surface), + authenticatedFetch: authFetch, + peerConnectionConfiguration: ice.configuration, + onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); }, + onError: (error) => { + if (remoteViewedScreen !== viewing) return; + if (isRemoteAuthorizationError(error)) { + showDetail("Remote screen authorization ended."); + releaseRemoteViewer("unavailable", "dispose"); + } else { + remotePanelStatus(surface.screenId, "reconnecting"); + } + }, + }); + viewing = { screenId: surface.screenId, officeId: surface.officeId, video, texture, remote, bound: false }; + remoteViewerRequestScreenId = null; + remoteViewedScreen = viewing; + await remote.startViewer({ viewerOptIn: true, video }); + if (remoteViewedScreen === viewing) showDetail(`Waiting for ${surface.screenId} remote video…`); + } catch (error) { + if (operation !== remoteMediaOperation) return; + remoteViewerRequestScreenId = null; + releaseRemoteViewer("unavailable", "dispose"); + remotePanelStatus(surface.screenId, "unavailable"); + showDetail(isRemoteAuthorizationError(error) + ? "Remote screen authorization ended. Sign in again to reconnect." + : "No authorized remote share is available for that screen."); + } +} + +async function startRemotePresenter( + surface: MediaSurfaceDescriptor, + shared: NonNullable, +): Promise { + if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; + remotePanelStatus(surface.screenId, "connecting"); + try { + const [{ createRemoteOfficeMedia }, { fetchEphemeralIceConfiguration }] = await Promise.all([ + import("./media/remoteMedia.ts"), + import("./media/iceClient.ts"), + ]); + if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; + const ice = await fetchEphemeralIceConfiguration({ authenticatedFetch: authFetch }); + if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return; + const remote = createRemoteOfficeMedia({ + role: "presenter", + binding: screenBinding(surface), + authenticatedFetch: authFetch, + peerConnectionConfiguration: ice.configuration, + onStateChange(state) { + if (sharedScreen !== shared) return; + if (state.status === "live") remotePanelStatus(surface.screenId, "live"); + else if (state.status === "reconnecting") remotePanelStatus(surface.screenId, "reconnecting"); + else if (state.status === "connecting") remotePanelStatus(surface.screenId, "connecting"); + else if (state.status === "stopped" || state.status === "revoked") { + showDetail("The hosted screen share ended."); + queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); }); + } + }, + onError: (error) => { + if (sharedScreen !== shared) return; + if (isRemoteAuthorizationError(error)) { + showDetail("Screen sharing stopped because authorization ended."); + queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); }); + } else { + remotePanelStatus(surface.screenId, "reconnecting"); + } + }, + }); + shared.remote = remote; + await remote.startPresenter({ consent: { authorized: true, optedIn: true }, stream: shared.stream, video: shared.video }); + if (sharedScreen === shared) showDetail(`Sharing ${surface.screenId} locally and to authorized remote viewers.`); + } catch (error) { + if (sharedScreen !== shared) return; + if (isRemoteAuthorizationError(error)) { + showDetail("Screen sharing stopped because authorization ended. Sign in again to share."); + stopLocalScreenShare(); + return; + } + void shared.remote?.dispose().catch(() => undefined); + shared.remote = null; + remotePanelStatus(surface.screenId, "unavailable"); + showDetail(`Sharing locally to ${surface.screenId}; hosted sharing is unavailable.`); + } +} + function stopLocalScreenShare(): void { const shared = sharedScreen; if (!shared) return; sharedScreen = null; + remoteMediaOperation += 1; office?.clearMediaSurface(shared.screenId); + remotePanelStatus(shared.screenId, "stopped"); + void shared.remote?.stop().catch(() => undefined); for (const track of shared.stream.getTracks()) track.stop(); shared.texture.dispose(); shared.video.pause(); @@ -2281,6 +2490,7 @@ function stopLocalScreenShare(): void { function disposeOfficeScreenUi(): void { stopLocalScreenShare(); + releaseRemoteViewer("off", "dispose"); officeScreenPanel?.dispose(); officeScreenPanel = null; if (screensOverlay) screensOverlay.hidden = true; @@ -2288,11 +2498,9 @@ function disposeOfficeScreenUi(): void { async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise { if (!office || !inside || office.depth !== "full") return; - const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false; - if (!optedIn) { - showDetail("Opt in to view this screen before starting a local preview."); - return; - } + // One authored surface role at a time. A viewer texture must not survive + // behind a new presenter preview or be cleared later over the presenter. + releaseRemoteViewer("off"); if (!navigator.mediaDevices?.getDisplayMedia) { showDetail("This browser does not provide tab or window sharing."); return; @@ -2331,7 +2539,8 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise { void startLocalScreenShare(surface); }, - onStopShare: () => stopLocalScreenShare(), + onSelect(surface) { + if ((remoteViewedScreen && remoteViewedScreen.screenId !== surface.screenId) || + (remoteViewerRequestScreenId !== null && remoteViewerRequestScreenId !== surface.screenId)) { + releaseRemoteViewer("off"); + } + }, + onStopShare: (surface) => { + if (sharedScreen?.screenId === surface.screenId) stopLocalScreenShare(); + if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) { + releaseRemoteViewer("stopped"); + } + }, onViewerOptIn(surface, optedIn) { - if (!optedIn && sharedScreen?.screenId === surface.screenId) stopLocalScreenShare(); + if (!optedIn) { + if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) { + releaseRemoteViewer("off"); + } else { + remotePanelStatus(surface.screenId, "off"); + } + } else if (access.subject !== null) { + void startRemoteViewer(surface); + } }, }); const syncOverlay = () => queueMicrotask(() => { diff --git a/src/media/iceClient.ts b/src/media/iceClient.ts new file mode 100644 index 0000000..07615d8 --- /dev/null +++ b/src/media/iceClient.ts @@ -0,0 +1,90 @@ +import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts"; +import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts"; + +export interface EphemeralIceConfiguration { + readonly configuration: RTCConfiguration; + readonly expiresAtMs: number; +} + +export interface FetchEphemeralIceConfigurationOptions { + authenticatedFetch: typeof globalThis.fetch; + endpoint?: string; + now?: () => number; + requestId?: () => string; +} + +/** A validated, correlated server backoff response. No credential is retained. */ +export class IceConfigurationUnavailableError extends Error { + readonly name = "IceConfigurationUnavailableError"; + readonly retryAfterMs: number; + readonly status: number; + + constructor(retryAfterMs: number, status: number) { + super("ICE configuration is temporarily unavailable"); + this.retryAfterMs = retryAfterMs; + this.status = status; + } +} + +/** + * Fetches one short-lived WebRTC configuration after the caller has established + * authenticated, explicit media intent. The result is never cached or stored; + * its lifetime remains visible so the caller can discard it on expiry. + */ +export async function fetchEphemeralIceConfiguration( + options: FetchEphemeralIceConfigurationOptions, +): Promise { + if (typeof options.authenticatedFetch !== "function") { + throw new TypeError("ICE configuration: authenticated fetch is required"); + } + const now = options.now ?? Date.now; + const id = (options.requestId ?? (() => `tera-ice-${crypto.randomUUID()}`))(); + const response = await options.authenticatedFetch(options.endpoint ?? "/api/v1/media/ice", { + method: "POST", + credentials: "same-origin", + cache: "no-store", + redirect: "error", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "ice-config-request", + protocolVersion: ICE_CONFIG_PROTOCOL_VERSION, + requestId: id, + }), + }); + let body: unknown; + try { + body = await response.json() as unknown; + } catch { + if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`); + throw new Error("ICE configuration response is not valid JSON"); + } + const parsed = parseIceConfigResponse(body); + if (!parsed.ok) { + if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`); + throw new Error(parsed.error); + } + if (parsed.value.requestId !== id) throw new Error("ICE configuration response did not match the request"); + if (parsed.value.type === "ice-config-unavailable") { + if (response.ok || response.status === 429 || response.status === 503) { + throw new IceConfigurationUnavailableError(parsed.value.retryAfterMs, response.status); + } + throw new Error(`ICE configuration request failed (${response.status})`); + } + if (!response.ok) throw new Error(`ICE configuration request failed (${response.status})`); + if (!isIceConfigGrantActive(parsed.value, now())) { + throw new Error("ICE configuration grant is not active"); + } + return { + configuration: { + iceServers: parsed.value.iceServers.map((server) => "username" in server + ? { + urls: [...server.urls], + username: server.username, + credential: server.credential, + credentialType: server.credentialType, + } + : { urls: [...server.urls] }), + }, + expiresAtMs: parsed.value.expiresAtMs, + }; +} diff --git a/src/media/iceTypes.ts b/src/media/iceTypes.ts new file mode 100644 index 0000000..0c2e159 --- /dev/null +++ b/src/media/iceTypes.ts @@ -0,0 +1,42 @@ +/** JSON-only contract for fetching ephemeral WebRTC ICE configuration. */ + +export const ICE_CONFIG_PROTOCOL_VERSION = 1 as const; + +export interface IceConfigRequest { + type: "ice-config-request"; + protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION; + requestId: string; +} + +export interface StunIceServer { + urls: readonly string[]; +} + +export interface TurnIceServer { + urls: readonly string[]; + username: string; + credential: string; + credentialType: "password"; +} + +export interface IceConfigGrant { + type: "ice-config-grant"; + protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION; + requestId: string; + issuedAtMs: number; + expiresAtMs: number; + iceServers: readonly (StunIceServer | TurnIceServer)[]; +} + +export interface IceConfigUnavailable { + type: "ice-config-unavailable"; + protocolVersion: typeof ICE_CONFIG_PROTOCOL_VERSION; + requestId: string; + retryAfterMs: number; +} + +export type IceConfigResponse = IceConfigGrant | IceConfigUnavailable; + +export type IceConfigValidationResult = + | { ok: true; value: T } + | { ok: false; error: string }; diff --git a/src/media/iceValidation.ts b/src/media/iceValidation.ts new file mode 100644 index 0000000..070287b --- /dev/null +++ b/src/media/iceValidation.ts @@ -0,0 +1,123 @@ +import { + ICE_CONFIG_PROTOCOL_VERSION, + type IceConfigGrant, + type IceConfigRequest, + type IceConfigResponse, + type IceConfigValidationResult, + type StunIceServer, + type TurnIceServer, +} from "./iceTypes.ts"; + +export const MAX_ICE_URLS = 8; +export const MAX_ICE_CREDENTIAL_LIFETIME_MS = 3_600_000; + +type JsonRecord = Record; + +export function isSafeIceUrl(value: unknown): value is string { + if (typeof value !== "string" || value.length < 6 || value.length > 512) return false; + if (/[\u0000-\u0020\u007f]/.test(value) || /[/#@]/.test(value)) return false; + // TURN's one useful query parameter is the transport selector. Accept that + // exact grammar so deployments can offer UDP, TCP, and TLS fallbacks without + // opening a generic query-string channel for credentials or vendor options. + const match = /^(stun|stuns|turn|turns):([^?]+)(?:\?transport=(udp|tcp))?$/.exec(value); + if (!match) return false; + const scheme = match[1] as string; + const authority = match[2] as string; + const transport = match[3]; + if (transport !== undefined && scheme !== "turn" && scheme !== "turns") return false; + if (authority.startsWith("[") && /^\[[0-9a-fA-F:.]+\](?::[1-9][0-9]{0,4})?$/.test(authority)) { + return validPort(authority); + } + if (!/^[a-zA-Z0-9.-]+(?::[1-9][0-9]{0,4})?$/.test(authority)) return false; + const host = authority.replace(/:[0-9]+$/, ""); + return host.length <= 253 && !host.startsWith(".") && !host.endsWith(".") && + !host.includes("..") && validPort(authority); +} + +export function parseIceConfigRequest(value: unknown): IceConfigValidationResult { + if (!exact(value, ["type", "protocolVersion", "requestId"]) || + value.type !== "ice-config-request" || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || + !identifier(value.requestId)) return failure("invalid request"); + return success(value as unknown as IceConfigRequest); +} + +export function parseIceConfigResponse(value: unknown): IceConfigValidationResult { + if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) { + return failure("invalid response envelope"); + } + if (value.type === "ice-config-unavailable") { + if (!exact(value, ["type", "protocolVersion", "requestId", "retryAfterMs"]) || + !boundedInteger(value.retryAfterMs, 1_000, 3_600_000)) return failure("invalid unavailable response"); + return success(value as unknown as IceConfigResponse); + } + if (value.type !== "ice-config-grant" || + !exact(value, ["type", "protocolVersion", "requestId", "issuedAtMs", "expiresAtMs", "iceServers"]) || + !timestamp(value.issuedAtMs) || !timestamp(value.expiresAtMs) || + value.expiresAtMs <= value.issuedAtMs || + value.expiresAtMs - value.issuedAtMs > MAX_ICE_CREDENTIAL_LIFETIME_MS || + !Array.isArray(value.iceServers) || value.iceServers.length < 1 || value.iceServers.length > 2) { + return failure("invalid grant"); + } + const servers = value.iceServers; + if (!servers.every(iceServer) || !servers.some(isTurnIceServer)) return failure("grant requires TURN"); + return success(value as unknown as IceConfigGrant); +} + +export function isIceConfigGrantActive(value: IceConfigGrant, nowMs: number): boolean { + return parseIceConfigResponse(value).ok && timestamp(nowMs) && + nowMs >= value.issuedAtMs && nowMs < value.expiresAtMs; +} + +function iceServer(value: unknown): value is StunIceServer | TurnIceServer { + if (!plain(value)) return false; + if (exact(value, ["urls"])) return urls(value.urls, ["stun:", "stuns:"]); + return isTurnIceServer(value); +} + +function isTurnIceServer(value: unknown): value is TurnIceServer { + return exact(value, ["urls", "username", "credential", "credentialType"]) && + urls(value.urls, ["turn:", "turns:"]) && opaque(value.username, 16, 256) && + opaque(value.credential, 20, 256) && value.credentialType === "password"; +} + +function urls(value: unknown, schemes: readonly string[]): value is readonly string[] { + return Array.isArray(value) && value.length > 0 && value.length <= MAX_ICE_URLS && + value.every((url) => isSafeIceUrl(url) && schemes.some((scheme) => url.startsWith(scheme))) && + new Set(value).size === value.length; +} + +function validPort(authority: string): boolean { + const match = /:([0-9]+)$/.exec(authority); + return match === null || Number(match[1]) <= 65_535; +} + +function timestamp(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 8_640_000_000_000_000; +} + +function boundedInteger(value: unknown, minimum: number, maximum: number): value is number { + return Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum; +} + +function identifier(value: unknown): value is string { + return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value); +} + +function opaque(value: unknown, minimum: number, maximum: number): value is string { + return typeof value === "string" && value.length >= minimum && value.length <= maximum && + /^[a-zA-Z0-9_:+/=.-]+$/.test(value); +} + +function plain(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype; +} + +function exact(value: unknown, keys: readonly string[]): value is JsonRecord { + if (!plain(value)) return false; + const actual = Object.keys(value); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function success(value: T): IceConfigValidationResult { return { ok: true, value }; } +function failure(error: string): IceConfigValidationResult { return { ok: false, error: `ice config: ${error}` }; } diff --git a/src/media/index.ts b/src/media/index.ts index 6e90087..1a0bf7b 100644 --- a/src/media/index.ts +++ b/src/media/index.ts @@ -21,6 +21,7 @@ export { createOfficeScreenPanel, type OfficeScreenPanel, type OfficeScreenPanelOptions, + type OfficeScreenRemoteStatus, type OfficeScreenPanelState, } from "./officeScreenPanel.ts"; export { @@ -43,3 +44,16 @@ export { parseScreenShareClientMessage, parseScreenShareServerMessage, } from "./signalingValidation.ts"; +export * from "./iceTypes.ts"; +export { + isIceConfigGrantActive, + isSafeIceUrl, + parseIceConfigRequest, + parseIceConfigResponse, +} from "./iceValidation.ts"; +export { + fetchEphemeralIceConfiguration, + IceConfigurationUnavailableError, + type EphemeralIceConfiguration, + type FetchEphemeralIceConfigurationOptions, +} from "./iceClient.ts"; diff --git a/src/media/officeScreenPanel.ts b/src/media/officeScreenPanel.ts index d5e4a94..ffb4675 100644 --- a/src/media/officeScreenPanel.ts +++ b/src/media/officeScreenPanel.ts @@ -2,6 +2,8 @@ import type { MediaSurfaceDescriptor } from "./presentation.ts"; +export type OfficeScreenRemoteStatus = "off" | "connecting" | "live" | "reconnecting" | "stopped" | "unavailable" | "error"; + export interface OfficeScreenPanelOptions { container: HTMLElement; surfaces: readonly MediaSurfaceDescriptor[]; @@ -19,6 +21,7 @@ export interface OfficeScreenPanelState { /** Explicit, local viewing choices. Empty initially and after disposal. */ optedInScreenIds: string[]; surfaces: MediaSurfaceDescriptor[]; + remoteStatusByScreen: Record; } export interface OfficeScreenPanel { @@ -27,6 +30,7 @@ export interface OfficeScreenPanel { update(surfaces: readonly MediaSurfaceDescriptor[]): OfficeScreenPanelState; close(): OfficeScreenPanelState; state(): OfficeScreenPanelState; + setRemoteStatus(screenId: string, status: OfficeScreenRemoteStatus): OfficeScreenPanelState; dispose(): void; } @@ -53,6 +57,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi let surfaces = validateSurfaces(options.surfaces); let selectedId: string | null = surfaces[0]?.screenId ?? null; const optedIn = new Set(); + const remoteStatuses = new Map(); let isOpen = false; let disposed = false; let invoker: HTMLElement | null = null; @@ -79,7 +84,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi const intro = doc.createElement("p"); intro.id = introId; intro.className = "tera-screen-panel__intro"; - intro.textContent = "Media is off by default. Choose a screen, then explicitly opt in to view or request sharing."; + intro.textContent = "Media is off by default. Opt in to view a remote screen, or choose Share screen to present your own."; const list = doc.createElement("div"); list.className = "tera-screen-panel__list"; list.setAttribute("role", "list"); @@ -97,6 +102,11 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi return surfaces.find((surface) => surface.screenId === selectedId) ?? null; } + function remoteCanStop(screenId: string): boolean { + const status = remoteStatuses.get(screenId); + return status === "connecting" || status === "live" || status === "reconnecting"; + } + function render(): void { for (const child of [...list.children]) child.remove(); if (surfaces.length === 0) { @@ -117,7 +127,15 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi meta.className = "tera-screen-panel__meta"; const room = surface.roomId ?? "Unassigned room"; const status = surface.bound ? "Media active" : "Media off"; - meta.textContent = `${surface.levelId} · ${room} · ${status}`; + const remote = remoteStatuses.get(surface.screenId) ?? "off"; + const remoteLabel = remote === "off" ? "Remote off" + : remote === "connecting" ? "Remote connecting" + : remote === "live" ? "Remote live" + : remote === "reconnecting" ? "Remote reconnecting" + : remote === "stopped" ? "Remote stopped" + : remote === "unavailable" ? "No remote share" + : "Remote error"; + meta.textContent = `${surface.levelId} · ${room} · ${status} · ${remoteLabel}`; row.append(name, meta); row.addEventListener("click", () => { selectedId = surface.screenId; @@ -129,15 +147,26 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi const surface = selected(); const viewing = surface ? optedIn.has(surface.screenId) : false; optButton.disabled = surface === null; - shareButton.disabled = surface === null || !viewing; - stopButton.disabled = surface === null || !surface.bound; - optButton.textContent = viewing ? "Stop viewing" : "Opt in to view"; + // Presenting is its own explicit action. Requiring viewer opt-in here would + // start a remote viewer join immediately before replacing it with capture. + shareButton.disabled = surface === null; + stopButton.disabled = surface === null || (!surface.bound && !remoteCanStop(surface.screenId)); + const retry = surface !== null && viewing && ["stopped", "unavailable", "error"] + .includes(remoteStatuses.get(surface.screenId) ?? "off"); + optButton.textContent = retry ? "Retry remote" : viewing ? "Stop viewing" : "Opt in to view"; optButton.setAttribute("aria-pressed", String(viewing)); } optButton.addEventListener("click", () => { const surface = selected(); if (!surface) return; + const remote = remoteStatuses.get(surface.screenId); + if (optedIn.has(surface.screenId) && + (remote === "stopped" || remote === "unavailable" || remote === "error")) { + render(); + options.onViewerOptIn?.(copySurface(surface), true); + return; + } const next = !optedIn.has(surface.screenId); if (next) optedIn.add(surface.screenId); else optedIn.delete(surface.screenId); @@ -150,7 +179,9 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi }); stopButton.addEventListener("click", () => { const surface = selected(); - if (surface?.bound) options.onStopShare?.(copySurface(surface)); + if (surface && (surface.bound || remoteCanStop(surface.screenId))) { + options.onStopShare?.(copySurface(surface)); + } }); closeButton.addEventListener("click", () => close()); @@ -160,6 +191,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi selectedId, optedInScreenIds: [...optedIn], surfaces: surfaces.map(copySurface), + remoteStatusByScreen: Object.fromEntries(remoteStatuses), }; } @@ -221,17 +253,29 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi surfaces = validateSurfaces(next); const ids = new Set(surfaces.map((surface) => surface.screenId)); for (const id of optedIn) if (!ids.has(id)) optedIn.delete(id); + for (const id of remoteStatuses.keys()) if (!ids.has(id)) remoteStatuses.delete(id); if (selectedId === null || !ids.has(selectedId)) selectedId = surfaces[0]?.screenId ?? null; render(); return snapshot(); }, close, state: snapshot, + setRemoteStatus(screenId, status) { + if (disposed || !surfaces.some((surface) => surface.screenId === screenId)) return snapshot(); + if (!["off", "connecting", "live", "reconnecting", "stopped", "unavailable", "error"].includes(status)) { + throw new TypeError("office screen panel: invalid remote status"); + } + if (status === "off") remoteStatuses.delete(screenId); + else remoteStatuses.set(screenId, status); + render(); + return snapshot(); + }, dispose() { if (disposed) return; close(); disposed = true; optedIn.clear(); + remoteStatuses.clear(); surfaces = []; selectedId = null; root.remove(); diff --git a/src/media/remoteMedia.ts b/src/media/remoteMedia.ts index ce210c2..f2f3776 100644 --- a/src/media/remoteMedia.ts +++ b/src/media/remoteMedia.ts @@ -60,11 +60,22 @@ export interface PresenterStart { stream: MediaStream; video: HTMLVideoElement; } -export interface ViewerStart { - /** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */ - decision: MediaAuthorizationDecision; - video: HTMLVideoElement; -} +export type ViewerStart = + | { + /** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */ + decision: MediaAuthorizationDecision; + viewerOptIn?: never; + video: HTMLVideoElement; + } + | { + /** + * Literal user action for signaling servers that perform authorization + * during join and intentionally expose no source locator to the client. + */ + decision?: never; + viewerOptIn: true; + video: HTMLVideoElement; + }; export interface RemoteOfficeMedia { startPresenter(input: PresenterStart): Promise; startViewer(input: ViewerStart): Promise; @@ -310,6 +321,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo cursor = advanced.value; if (message.type === "screen-share-stopped") { closeTransport(); + // The server has already destroyed the signaling session. Forget the + // capability now so caller cleanup cannot issue a redundant leave that + // is guaranteed to fail authorization. + grant = null; setStatus("stopped"); return; } @@ -412,7 +427,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo if (!message.continuous) resetPeerMesh(); await reconcile(message.participants); scheduleRenewal(); - setStatus("connecting"); + // A presenter with no viewers is still live: the hosted signaling session + // is accepting authorized viewers. A viewer remains connecting until its + // receive-only peer actually reaches the connected state. + setStatus(options.role === "presenter" && peers.size === 0 ? "live" : "connecting"); return true; } @@ -492,6 +510,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs }; await reconcile(response.participants); scheduleRenewal(); + if (role === "presenter" && peers.size === 0) setStatus("live"); void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media events failed")); scheduleReconnect(); }); } @@ -525,9 +544,12 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo 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") { + const ready = input.decision === undefined + ? input.viewerOptIn === true + : 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"; + if (!ready) { throw new Error("remote media: a ready live-stream authorization decision is required"); } input.video.autoplay = false; diff --git a/src/test/iceClient.test.ts b/src/test/iceClient.test.ts new file mode 100644 index 0000000..f058a58 --- /dev/null +++ b/src/test/iceClient.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + fetchEphemeralIceConfiguration, + IceConfigurationUnavailableError, +} from "../media/iceClient.ts"; + +describe("ephemeral ICE configuration client", () => { + it("posts the exact authenticated request and returns an in-memory RTC configuration", async () => { + let input: RequestInfo | URL | undefined; + let init: RequestInit | undefined; + const result = await fetchEphemeralIceConfiguration({ + authenticatedFetch: async (nextInput, nextInit) => { + input = nextInput; + init = nextInit; + return Response.json({ + type: "ice-config-grant", + protocolVersion: 1, + requestId: "ice-test-1", + issuedAtMs: 100, + expiresAtMs: 600_000, + iceServers: [ + { urls: ["stun:relay.example.test:3478"] }, + { + urls: ["turns:relay.example.test:5349"], + username: "temporary-user-01", + credential: "temporary-password-01", + credentialType: "password", + }, + ], + }); + }, + now: () => 200, + requestId: () => "ice-test-1", + }); + assert.equal(input, "/api/v1/media/ice"); + assert.equal(init?.method, "POST"); + assert.equal(init?.credentials, "same-origin"); + assert.equal(init?.cache, "no-store"); + assert.deepEqual(JSON.parse(String(init?.body)), { + type: "ice-config-request", + protocolVersion: 1, + requestId: "ice-test-1", + }); + assert.deepEqual(result.configuration.iceServers, [ + { urls: ["stun:relay.example.test:3478"] }, + { + urls: ["turns:relay.example.test:5349"], + username: "temporary-user-01", + credential: "temporary-password-01", + credentialType: "password", + }, + ]); + assert.equal(result.expiresAtMs, 600_000); + }); + + it("rejects unavailable, mismatched, expired, and malformed responses", async () => { + const run = (body: unknown) => fetchEphemeralIceConfiguration({ + authenticatedFetch: async () => Response.json(body), + now: () => 200, + requestId: () => "ice-test-1", + }); + await assert.rejects(run({ + type: "ice-config-unavailable", + protocolVersion: 1, + requestId: "ice-test-1", + retryAfterMs: 10_000, + }), /temporarily unavailable/); + await assert.rejects(run({ + type: "ice-config-unavailable", + protocolVersion: 1, + requestId: "ice-test-2", + retryAfterMs: 10_000, + }), /did not match/); + await assert.rejects(run({ + type: "ice-config-grant", + protocolVersion: 1, + requestId: "ice-test-1", + issuedAtMs: 10, + expiresAtMs: 100, + iceServers: [{ + urls: ["turn:relay.example.test:3478"], + username: "temporary-user-01", + credential: "temporary-password-01", + credentialType: "password", + }], + }), /not active/); + await assert.rejects(run({ profile: "must-not-parse" }), /invalid response/); + }); + + it("exposes validated 429 and 503 retry timing without exposing an untrusted body", async () => { + for (const status of [429, 503]) { + await assert.rejects(fetchEphemeralIceConfiguration({ + authenticatedFetch: async () => Response.json({ + type: "ice-config-unavailable", + protocolVersion: 1, + requestId: "ice-backoff-1", + retryAfterMs: 12_345, + }, { status }), + requestId: () => "ice-backoff-1", + }), (error: unknown) => { + assert.ok(error instanceof IceConfigurationUnavailableError); + assert.equal(error.retryAfterMs, 12_345); + assert.equal(error.status, status); + return true; + }); + } + + await assert.rejects(fetchEphemeralIceConfiguration({ + authenticatedFetch: async () => Response.json({ + type: "ice-config-unavailable", + protocolVersion: 1, + requestId: "somebody-elses-request", + retryAfterMs: 12_345, + }, { status: 503 }), + requestId: () => "ice-backoff-1", + }), /did not match/); + + await assert.rejects(fetchEphemeralIceConfiguration({ + authenticatedFetch: async () => Response.json({ retryAfterMs: 1 }, { status: 503 }), + requestId: () => "ice-backoff-1", + }), /failed \(503\)/); + }); +}); diff --git a/src/test/iceConfig.test.ts b/src/test/iceConfig.test.ts new file mode 100644 index 0000000..a376b6e --- /dev/null +++ b/src/test/iceConfig.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + isIceConfigGrantActive, + isSafeIceUrl, + parseIceConfigRequest, + parseIceConfigResponse, + type IceConfigGrant, +} from "../media/index.ts"; + +const grant = (): IceConfigGrant => ({ + type: "ice-config-grant", + protocolVersion: 1, + requestId: "ice-request-1", + issuedAtMs: 1_000, + expiresAtMs: 601_000, + iceServers: [ + { urls: ["stun:relay.example.test:3478"] }, + { + urls: ["turn:relay.example.test:3478", "turns:relay.example.test:5349"], + username: "1700000600:opaque_nonce_value", + credential: "dGVzdF9jcmVkZW50aWFsX3ZhbHVl", + credentialType: "password", + }, + ], +}); + +describe("ICE configuration wire contract", () => { + it("accepts exact requests and bounded ephemeral grants", () => { + assert.equal(parseIceConfigRequest({ + type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", + }).ok, true); + assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true); + assert.equal(isIceConfigGrantActive(grant(), 300_000), true); + assert.equal(isIceConfigGrantActive(grant(), 601_000), false); + }); + + it("allows only credential-free ICE URL forms", () => { + for (const url of [ + "stun:relay.example.test:3478", "stuns:relay.example.test:5349", + "turn:192.0.2.4:3478", "turn:relay.example.test:3478?transport=udp", + "turn:relay.example.test:3478?transport=tcp", "turns:[2001:db8::1]:5349?transport=tcp", + ]) { + assert.equal(isSafeIceUrl(url), true, url); + } + for (const url of [ + "https://relay.example.test", "turn:user:pass@relay.example.test:3478", + "turn:relay.example.test:3478?credential=secret", "turn://relay.example.test:3478", + "stun:relay.example.test:3478?transport=udp", "turn:relay.example.test:3478?transport=sctp", + "turn:relay.example.test:3478?transport=tcp&credential=secret", + "turn:relay.example.test:99999", "turn:relay example.test:3478", + ]) assert.equal(isSafeIceUrl(url), false, url); + }); + + it("rejects extra keys, credential tricks, missing TURN, and excessive lifetime", () => { + assert.equal(parseIceConfigRequest({ + type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", subject: "stable-user", + }).ok, false); + assert.equal(parseIceConfigResponse({ ...grant(), identity: { profile: "karti" } }).ok, false); + assert.equal(parseIceConfigResponse({ ...grant(), iceServers: [{ urls: ["stun:relay.example.test:3478"] }] }).ok, false); + assert.equal(parseIceConfigResponse({ ...grant(), expiresAtMs: 3_601_001 }).ok, false); + const turn = grant().iceServers[1] as unknown as Record; + assert.equal(parseIceConfigResponse({ + ...grant(), iceServers: [{ ...turn, urls: ["turn:user@relay.example.test:3478"] }], + }).ok, false); + }); +}); diff --git a/src/test/officeScreenPanel.test.ts b/src/test/officeScreenPanel.test.ts index 8cdd718..8d6fed4 100644 --- a/src/test/officeScreenPanel.test.ts +++ b/src/test/officeScreenPanel.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { createOfficeScreenPanel, type MediaSurfaceDescriptor } from "../media/index.ts"; +import { createOfficeScreenPanel } from "../media/officeScreenPanel.ts"; +import type { MediaSurfaceDescriptor } from "../media/presentation.ts"; type Listener = (event: FakeEvent) => void; class FakeEvent { @@ -121,6 +122,18 @@ describe("office screen manager panel", () => { assert.equal("mediaDevices" in panel, false); }); + it("keeps presenting separate from viewer opt-in", () => { + const { panel, shares, opts } = setup(); + panel.open(); + const root = panel.root as unknown as FakeElement; + const share = root.find("data-action", "share"); + assert.equal(share.disabled, false); + share.dispatch("click"); + assert.deepEqual(shares, ["lobby-monitor"]); + assert.deepEqual(opts, []); + assert.deepEqual(panel.state().optedInScreenIds, []); + }); + it("updates defensively, removes stale consent, and represents an empty office", () => { const { panel } = setup(); const root = panel.root as unknown as FakeElement; @@ -136,6 +149,39 @@ describe("office screen manager panel", () => { assert.match(root.text(), /No authored office screens/); }); + it("shows concise remote lifecycle states and forgets them with their screen", () => { + const { panel, stops, opts } = setup(); + const root = panel.root as unknown as FakeElement; + root.find("data-action", "opt-in").dispatch("click"); + panel.setRemoteStatus("lobby-monitor", "connecting"); + assert.equal(panel.state().remoteStatusByScreen["lobby-monitor"], "connecting"); + assert.match(root.text(), /Remote connecting/); + assert.equal(root.find("data-action", "stop").disabled, false); + root.find("data-action", "stop").dispatch("click"); + assert.deepEqual(stops, ["lobby-monitor"]); + panel.setRemoteStatus("lobby-monitor", "unavailable"); + assert.match(root.text(), /No remote share/); + assert.equal(root.find("data-action", "opt-in").textContent, "Retry remote"); + root.find("data-action", "opt-in").dispatch("click"); + assert.deepEqual(opts.at(-1), ["lobby-monitor", true]); + assert.deepEqual(panel.state().optedInScreenIds, ["lobby-monitor"]); + panel.update([SURFACES[1]!]); + assert.deepEqual(panel.state().remoteStatusByScreen, {}); + assert.throws(() => panel.setRemoteStatus("commons-display", "invalid" as "live"), /invalid remote status/); + }); + + it("refreshes a remote surface from bound back to cleared", () => { + const { panel } = setup(); + const root = panel.root as unknown as FakeElement; + panel.setRemoteStatus("lobby-monitor", "live"); + panel.update([{ ...SURFACES[0]!, bound: true }, SURFACES[1]!]); + assert.match(root.text(), /Media active · Remote live/); + panel.update(SURFACES); + panel.setRemoteStatus("lobby-monitor", "off"); + assert.match(root.text(), /Media off · Remote off/); + assert.equal(root.find("data-action", "stop").disabled, true); + }); + it("traps tab focus, closes on Escape, restores focus, and disposes idempotently", () => { const { document, container, panel } = setup(); const trigger = document.createElement("button"); diff --git a/src/test/remoteMedia.test.ts b/src/test/remoteMedia.test.ts index da6c819..4b53107 100644 --- a/src/test/remoteMedia.test.ts +++ b/src/test/remoteMedia.test.ts @@ -182,6 +182,7 @@ describe("remote office media presenter", () => { /explicit opt-in/, ); await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: stream as unknown as MediaStream, video: video as unknown as HTMLVideoElement }); + assert.equal(media.state().status, "live", "an empty hosted room is ready for its first viewer"); await settle(); assert.equal(peers.length, 1); assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack); @@ -324,7 +325,7 @@ describe("remote office media viewer", () => { }); 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 media.startViewer({ viewerOptIn: true, 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" }); @@ -340,4 +341,39 @@ describe("remote office media viewer", () => { assert.equal(video.srcObject, null); assert.equal(media.state().status, "revoked"); }); + + it("forgets its grant when the server has already stopped the session", async () => { + const presenterPeer = { participantId: "presenter-opaque", role: "presenter" as const }; + let emitEvent: (value: Uint8Array) => void = () => { throw new Error("event stream is not open"); }; + let leaveCalls = 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-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 }); + } + if (String(input).endsWith("/events")) { + const stream = new ReadableStream({ start(controller) { emitEvent = (value) => controller.enqueue(value); } }); + const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]); + emitEvent(new TextEncoder().encode(`data: ${JSON.stringify(resume)}\n\n`)); + return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream" } }); + } + if (String(input).endsWith("/leave")) leaveCalls += 1; + return new Response(null, { status: 204 }); + }; + const media = createRemoteOfficeMedia({ + role: "viewer", binding: BINDING, authenticatedFetch: fetcher, now: () => NOW, + peerConnectionFactory: () => new FakePeer() as unknown as RTCPeerConnection, + }); + await media.startViewer({ viewerOptIn: true, video: new FakeVideo() as unknown as HTMLVideoElement }); + await settle(); + emitEvent(new TextEncoder().encode(`data: ${JSON.stringify({ + type: "screen-share-stopped", protocolVersion: 1, sequence: 2, timestampMs: NOW + 1, + sessionId: "share-session", binding: BINDING, reason: "presenter-stopped", + })}\n\n`)); + await settle(); + assert.equal(media.state().status, "stopped"); + assert.equal(media.state().sessionId, null); + await media.dispose(); + assert.equal(leaveCalls, 0, "cleanup does not reuse a server-invalidated capability"); + }); });