1
0

feat: stream private office screens

This commit is contained in:
2026-08-11 21:31:15 -07:00
parent d841575315
commit 5ca214e4bb
28 changed files with 1754 additions and 35 deletions
+3
View File
@@ -32,6 +32,9 @@ tera.example.com {
file_server file_server
header { 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'" 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=()"
} }
} }
``` ```
+227
View File
@@ -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 | 5200053023 | 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 5000050200; 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=<same 64 hex characters>
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: `<expiry-unix-seconds>:<random-opaque-nonce>`
- 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
turnutils_stunclient -p 3478 turn.lumbridgecorp.com
```
Obtain a temporary credential through the authenticated ICE endpoint rather
than reading the shared secret. Exercise it with `turnutils_uclient` and with a
browser `RTCPeerConnection` configured with `iceTransportPolicy: "relay"`.
Verify through `getStats()` or browser WebRTC diagnostics that:
- the selected candidate type is `relay`;
- the relay address is the current public IP, never the private VNIC address;
- two peers on different networks can exchange a screen track;
- blocking UDP forces TURN/TCP 3478, then TURN/TLS 5349;
- expired TURN credentials cannot create or refresh allocations;
- signaling revocation closes the application's peer connections, but an already-issued
stateless TURN credential remains usable until its short expiry (and an existing
allocation until coturn's configured allocation lifetime);
- presenter stop and viewer leave close their browser peer connections;
- no CSP or Permissions Policy violation appears on either Tera entry host.
Finally test certificate renewal/reload and monitor allocation count, port
exhaustion, authentication failures and relay bandwidth. TURN is an egress and
abuse boundary; quota increases require the same operator review as firewall
changes. TLS on 5349 will not cross networks that allow only destination 443.
Supporting `turns:443` requires a separate public IP or carefully tested L4/SNI
multiplexing and is deliberately outside this minimal deployment.
## Upstream references
- [coturn server options and REST authentication](https://github.com/coturn/coturn/blob/master/README.turnserver)
- [coturn container/networking notes](https://github.com/coturn/coturn/blob/master/docker/coturn/README.md)
- [Let's Encrypt port 80 guidance](https://letsencrypt.org/docs/allow-port-80/)
- [Certbot webroot and deploy-hook documentation](https://eff-certbot.readthedocs.io/en/stable/using.html)
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Certbot deploy hook: install only the renewed turn certificate, then ask the
# distro unit to reload or restart. A restart can interrupt active allocations;
# schedule renewal windows and verify the unit's ExecReload before production.
set -eu
: "${RENEWED_LINEAGE:?certbot did not provide RENEWED_LINEAGE}"
openssl x509 -in "$RENEWED_LINEAGE/fullchain.pem" -noout \
-checkhost 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
@@ -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
+29
View File
@@ -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
+12
View File
@@ -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
}
+69
View File
@@ -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
+3
View File
@@ -42,6 +42,9 @@ services:
TERA_AUTH_ENTRY_URL: "${TERA_AUTH_ENTRY_URL:-}" TERA_AUTH_ENTRY_URL: "${TERA_AUTH_ENTRY_URL:-}"
TERA_AUTH_REVALIDATE_URL: "${TERA_AUTH_REVALIDATE_URL:-}" TERA_AUTH_REVALIDATE_URL: "${TERA_AUTH_REVALIDATE_URL:-}"
TERA_AUTH_JWT_SECRET: "${TERA_AUTH_JWT_SECRET:-}" 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 # Offices and marker snapshots are files. Mount them read-only where you
# keep them; the container writes nothing, ever. # keep them; the container writes nothing, ever.
# #
+25 -1
View File
@@ -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/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/pose` | — | one owned `EntityPoseSnapshot` | **private; never cached** |
| `POST /api/v1/realtime/leave` | — | session id + opaque resume token | **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/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/events` | — | fetch-streamed SSE opened with a strict resume request | **private; never cached** |
| `POST /api/v1/media/signal` | — | strict targeted SDP/ICE request | **private; never cached** | | `POST /api/v1/media/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 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/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. contracts without either transport becoming a dependency of the other.
Both location parameters are optional and omitting them answers for the default 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 must close missing/revoked peers, and the short lease bounds disconnected
clients that miss an event. 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 ## Regions
**A caller's coordinate is never forwarded upstream. It only selects among the **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_JWT_VERIFY` | `hs256` | Set to `jwks` for asymmetric verification. |
| `TERA_AUTH_JWKS_URL` | *(empty)* | | | `TERA_AUTH_JWKS_URL` | *(empty)* | |
| `TERA_AUTH_JWT_ISSUER` / `_AUDIENCE` | *(empty)* | Checked when set. | | `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, 324096 bytes. Never expose this in the static build. |
| `TERA_TURN_CREDENTIAL_TTL` | `300` | Credential lifetime in seconds, bounded to 603600. |
| `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 13600. |
A self-hoster gets `none`, an open office, and never creates an account 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 anywhere. `sso` is what Lumbridge's own deployment uses: this world holds **no
+60
View File
@@ -20,6 +20,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { parseScryptHash, type ScryptHash } from "./auth/password.ts"; import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
import { loadRegions, type RegionSet } from "./regions.ts"; import { loadRegions, type RegionSet } from "./regions.ts";
import { isSafeIceUrl } from "../../src/media/iceValidation.ts";
import type { import type {
AuthMode, AuthMode,
FlightsSourceId, FlightsSourceId,
@@ -136,6 +137,16 @@ export interface AuthConfig {
admins: AdminGrant; 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 { export interface Config {
host: string; host: string;
port: number; port: number;
@@ -168,6 +179,7 @@ export interface Config {
*/ */
presence: { dir: string }; presence: { dir: string };
auth: AuthConfig; auth: AuthConfig;
ice: IceConfig;
/** One sentence per demotion. Empty on a fully-configured box. */ /** One sentence per demotion. Empty on a fully-configured box. */
degraded: string[]; degraded: string[];
} }
@@ -181,6 +193,7 @@ export function loadConfig(env: Env = process.env): Config {
const flights = loadFlights(env, degraded); const flights = loadFlights(env, degraded);
const satellites = loadSatellites(env, degraded); const satellites = loadSatellites(env, degraded);
const auth = loadAuth(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 // 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. // sentence and the sentence is only true once `mode` has finished demoting.
const markers = loadMarkers(env, auth.mode, degraded); 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", "") }, offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
presence: { dir: str(env, "TERA_PRESENCE_DIR", "") }, presence: { dir: str(env, "TERA_PRESENCE_DIR", "") },
auth, auth,
ice,
degraded, degraded,
}; };
} }
// ---- Sections ------------------------------------------------------------- // ---- 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"]; const WEATHER_SOURCES: WeatherSourceId[] = ["none", "nws", "metno", "openmeteo"];
function loadWeather(env: Env, degraded: string[]): WeatherConfig { function loadWeather(env: Env, degraded: string[]): WeatherConfig {
+100
View File
@@ -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<string, RateEntry>();
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<StunIceServer | TurnIceServer> = [];
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))),
},
};
}
+6
View File
@@ -6,3 +6,9 @@ export {
type MediaSignalServiceOptions, type MediaSignalServiceOptions,
} from "./service.ts"; } from "./service.ts";
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts"; export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
export {
createIceCredentialProvider,
type IceCredentialProvider,
type IceCredentialProviderOptions,
type IceCredentialResult,
} from "./ice.ts";
+33 -8
View File
@@ -63,6 +63,7 @@ interface Participant {
rateStartedAt: number; rateStartedAt: number;
rateCount: number; rateCount: number;
queue: ScreenSharePeerMessage[]; queue: ScreenSharePeerMessage[];
queueOverflowed: boolean;
listeners: Set<(message: ScreenSharePeerMessage) => void>; 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 // snapshot, so dropping the oldest relay is safer than letting a sender
// evict another participant by filling its queue. // evict another participant by filling its queue.
participant.queue.shift(); participant.queue.shift();
participant.queueOverflowed = true;
} }
participant.queue.push(message); participant.queue.push(message);
} }
@@ -207,10 +209,9 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
sequence: number, sequence: number,
): MediaSignalResult<null> { ): MediaSignalResult<null> {
if (!sameBinding(session.binding, binding)) return { ok: false, code: "unauthorized", message: "Media binding does not match the grant." }; 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." }; return { ok: false, code: "conflict", message: "Media client sequence is not monotonic." };
} }
participant.lastClientSequence = sequence;
return { ok: true, value: null }; return { ok: true, value: null };
} }
@@ -220,10 +221,14 @@ export function createMediaSignalService(options: MediaSignalServiceOptions = {}
return { token, participant: { return { token, participant: {
id: opaque(18), subject, role, tokenHash: hash(token), issuedAt, expiresAt: issuedAt + ttl, id: opaque(18), subject, role, tokenHash: hash(token), issuedAt, expiresAt: issuedAt + ttl,
lastClientSequence: clientSequence, rateStartedAt: issuedAt, rateCount: 0, queue: [], listeners: new Set(), lastClientSequence: clientSequence, rateStartedAt: issuedAt, rateCount: 0, queue: [], listeners: new Set(),
queueOverflowed: false,
} }; } };
} }
function join(request: ScreenShareCreateRequest | ScreenShareJoinRequest, subject: string): MediaSignalResult<ScreenShareCreateGrant | ScreenShareJoinGrant> { function join(request: ScreenShareCreateRequest | ScreenShareJoinRequest, subject: string): MediaSignalResult<ScreenShareCreateGrant | ScreenShareJoinGrant> {
if (!clientSequenceHasSuccessor(request.sequence)) {
return { ok: false, code: "invalid", message: "Media client sequence cannot advance." };
}
cleanup(); cleanup();
if (request.type === "screen-share-create-request") { if (request.type === "screen-share-create-request") {
if (sessions.size >= maximumSessions) return { ok: false, code: "capacity", message: "Media signaling is at capacity." }; 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); const found = authenticate(request.credential, subject);
if (!found.ok) return found; if (!found.ok) return found;
const { session, participant } = found.value; 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); const valid = validateRequest(session, participant, request.binding, request.sequence);
if (!valid.ok) return valid; if (!valid.ok) return valid;
participant.lastClientSequence = request.sequence;
const token = opaque(32); const token = opaque(32);
participant.tokenHash.fill(0); participant.tokenHash.fill(0);
participant.tokenHash = hash(token); participant.tokenHash = hash(token);
participant.issuedAt = now(); participant.issuedAt = now();
participant.expiresAt = now() + ttl; participant.expiresAt = now() + ttl;
// A resume grant carries a complete participant snapshot. Dropping queued const continuous = !participant.queueOverflowed;
// relays avoids replaying a message with a sequence older than that grant; const queued = participant.queue.filter((message) => message.sequence > request.lastReceivedSequence);
// clients renegotiate when `continuous` is false. const grantSequence = nextSequence(session);
participant.queue.length = 0; 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: { return { ok: true, value: {
type: "screen-share-resume-grant", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION, 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, 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"; 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." }; if (!permitted) return { ok: false, code: "unauthorized", message: "Media SDP role is not permitted." };
} }
participant.lastClientSequence = request.sequence;
const relay: ScreenShareSignalRelay = { const relay: ScreenShareSignalRelay = {
type: "screen-share-signal-relay", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION, type: "screen-share-signal-relay", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding, 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 (!valid.ok) return valid;
if (request.type === "screen-share-stop-request") { if (request.type === "screen-share-stop-request") {
if (participant.role !== "presenter") return { ok: false, code: "unauthorized", message: "Only a presenter may stop a share." }; 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)); destroySession(session, stopNotice(session, request.reason));
return { ok: true, value: null }; return { ok: true, value: null };
} }
if (request.reason === "moderator-action" && !admin) return { ok: false, code: "unauthorized", message: "Moderator action requires an administrator." }; if (request.reason === "moderator-action" && !admin) return { ok: false, code: "unauthorized", message: "Moderator action requires an administrator." };
if (request.scope === "session") { if (request.scope === "session") {
if (participant.role !== "presenter" && !admin) return { ok: false, code: "unauthorized", message: "Only a presenter or administrator may revoke a 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, { destroySession(session, {
type: "screen-share-revoked", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION, type: "screen-share-revoked", protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
sequence: nextSequence(session), timestampMs: now(), sessionId: session.id, binding: session.binding, 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." }; 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." }; if (target.role === "presenter") return { ok: false, code: "unauthorized", message: "Presenter must stop the session." };
participant.lastClientSequence = request.sequence;
removeParticipant(session, target, request.reason); removeParticipant(session, target, request.reason);
return { ok: true, value: null }; 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; 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 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 copyBinding(binding: ScreenShareBinding): ScreenShareBinding { return { ...binding }; }
function sameBinding(a: ScreenShareBinding, b: ScreenShareBinding): boolean { function sameBinding(a: ScreenShareBinding, b: ScreenShareBinding): boolean {
return a.officeId === b.officeId && a.levelId === b.levelId && a.roomId === b.roomId && a.screenId === b.screenId; return a.officeId === b.officeId && a.levelId === b.levelId && a.roomId === b.roomId && a.screenId === b.screenId;
+18
View File
@@ -2,6 +2,7 @@
import type { FastifyInstance, FastifyReply } from "fastify"; import type { FastifyInstance, FastifyReply } from "fastify";
import { parseScreenShareClientMessage } from "../../../src/media/signalingValidation.ts"; import { parseScreenShareClientMessage } from "../../../src/media/signalingValidation.ts";
import { parseIceConfigRequest } from "../../../src/media/iceValidation.ts";
import type { import type {
ScreenShareBinding, ScreenShareBinding,
ScreenShareCreateRequest, ScreenShareCreateRequest,
@@ -43,6 +44,23 @@ function parse(body: unknown) {
} }
export function registerMedia(app: FastifyInstance, services: Services): void { 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) => { app.post<{ Body: unknown }>("/api/v1/media/join", { bodyLimit: BODY_LIMIT }, async (req, reply) => {
const viewer = await services.auth.resolve(req); const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) { if (!viewer.authenticated || viewer.subject === null) {
+8 -1
View File
@@ -10,7 +10,12 @@
import { createAuth, type AuthService } from "./auth/index.ts"; import { createAuth, type AuthService } from "./auth/index.ts";
import { createFlightsService, type FlightsService } from "./flights/index.ts"; import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.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 { createOfficeStore, type OfficeStore } from "./offices/store.ts";
import { createPresenceStore, type PresenceStore } from "./presence/store.ts"; import { createPresenceStore, type PresenceStore } from "./presence/store.ts";
import { createRealtimeService, type RealtimeService } from "./realtime/index.ts"; import { createRealtimeService, type RealtimeService } from "./realtime/index.ts";
@@ -25,6 +30,7 @@ export interface Services {
satellites: SatellitesService; satellites: SatellitesService;
markers: MarkerStore; markers: MarkerStore;
media: MediaSignalService; media: MediaSignalService;
ice: IceCredentialProvider;
offices: OfficeStore; offices: OfficeStore;
presence: PresenceStore; presence: PresenceStore;
realtime: RealtimeService; realtime: RealtimeService;
@@ -46,6 +52,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
satellites: createSatellitesService(config, log), satellites: createSatellitesService(config, log),
markers: createMarkerStore(config, log), markers: createMarkerStore(config, log),
media: createMediaSignalService(), media: createMediaSignalService(),
ice: createIceCredentialProvider(config.ice),
offices: createOfficeStore(config.offices.dir), offices: createOfficeStore(config.offices.dir),
presence: createPresenceStore(config.presence.dir), presence: createPresenceStore(config.presence.dir),
realtime: createRealtimeService(), realtime: createRealtimeService(),
+137
View File
@@ -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> = {}): 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<IceConfigGrant>();
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);
});
});
+135
View File
@@ -76,6 +76,87 @@ describe("office media signaling service", () => {
assert.equal(service.sessionCount(), 0); 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", () => { it("bounds the mesh and sends viewer lifecycle snapshots", () => {
const service = createMediaSignalService({ maximumParticipantsPerSession: 2 }); const service = createMediaSignalService({ maximumParticipantsPerSession: 2 });
const { presenter, viewer } = grants(service); const { presenter, viewer } = grants(service);
@@ -163,4 +244,58 @@ describe("office media signaling routes", () => {
assert.equal(streamed.url.includes("grantToken"), false); assert.equal(streamed.url.includes("grantToken"), false);
controller.abort(); 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);
});
}); });
+239 -8
View File
@@ -89,7 +89,9 @@ import {
createOfficeScreenPanel, createOfficeScreenPanel,
type MediaSurfaceDescriptor, type MediaSurfaceDescriptor,
type OfficeScreenPanel, type OfficeScreenPanel,
type OfficeScreenRemoteStatus,
} from "./media/index.ts"; } 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 * Three type-only imports and not one value among them, which is what keeps the
* office and the instruments out of the entry chunk. * office and the instruments out of the entry chunk.
@@ -454,7 +456,18 @@ let sharedScreen: {
stream: MediaStream; stream: MediaStream;
video: HTMLVideoElement; video: HTMLVideoElement;
texture: VideoTexture; texture: VideoTexture;
remote: RemoteOfficeMedia | null;
} | null = 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. * The plan panel's other occupant.
* *
@@ -2194,6 +2207,7 @@ async function initializeRealtimePresence(): Promise<void> {
window.addEventListener("pagehide", () => { window.addEventListener("pagehide", () => {
realtimePageActive = false; realtimePageActive = false;
disposeOfficeScreenUi();
realtimeOperation += 1; realtimeOperation += 1;
stopRealtimeSubscription?.(); stopRealtimeSubscription?.();
stopRealtimeSubscription = null; stopRealtimeSubscription = null;
@@ -2266,11 +2280,206 @@ function openProfileEditor(): void {
editor.open(); 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<typeof remoteViewedScreen>,
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<void> {
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<typeof remoteViewedScreen>;
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<typeof sharedScreen>,
): Promise<void> {
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 { function stopLocalScreenShare(): void {
const shared = sharedScreen; const shared = sharedScreen;
if (!shared) return; if (!shared) return;
sharedScreen = null; sharedScreen = null;
remoteMediaOperation += 1;
office?.clearMediaSurface(shared.screenId); office?.clearMediaSurface(shared.screenId);
remotePanelStatus(shared.screenId, "stopped");
void shared.remote?.stop().catch(() => undefined);
for (const track of shared.stream.getTracks()) track.stop(); for (const track of shared.stream.getTracks()) track.stop();
shared.texture.dispose(); shared.texture.dispose();
shared.video.pause(); shared.video.pause();
@@ -2281,6 +2490,7 @@ function stopLocalScreenShare(): void {
function disposeOfficeScreenUi(): void { function disposeOfficeScreenUi(): void {
stopLocalScreenShare(); stopLocalScreenShare();
releaseRemoteViewer("off", "dispose");
officeScreenPanel?.dispose(); officeScreenPanel?.dispose();
officeScreenPanel = null; officeScreenPanel = null;
if (screensOverlay) screensOverlay.hidden = true; if (screensOverlay) screensOverlay.hidden = true;
@@ -2288,11 +2498,9 @@ function disposeOfficeScreenUi(): void {
async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> { async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
if (!office || !inside || office.depth !== "full") return; if (!office || !inside || office.depth !== "full") return;
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false; // One authored surface role at a time. A viewer texture must not survive
if (!optedIn) { // behind a new presenter preview or be cleared later over the presenter.
showDetail("Opt in to view this screen before starting a local preview."); releaseRemoteViewer("off");
return;
}
if (!navigator.mediaDevices?.getDisplayMedia) { if (!navigator.mediaDevices?.getDisplayMedia) {
showDetail("This browser does not provide tab or window sharing."); showDetail("This browser does not provide tab or window sharing.");
return; return;
@@ -2331,7 +2539,8 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
showDetail("That screen is no longer available."); showDetail("That screen is no longer available.");
return; return;
} }
sharedScreen = { screenId: surface.screenId, stream, video, texture }; const shared = { screenId: surface.screenId, stream, video, texture, remote: null };
sharedScreen = shared;
pendingStream = null; pendingStream = null;
pendingVideo = null; pendingVideo = null;
pendingTexture = null; pendingTexture = null;
@@ -2339,6 +2548,9 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
officeScreenPanel?.update(office.listMediaSurfaces()); officeScreenPanel?.update(office.listMediaSurfaces());
renderOfficeBadge(); renderOfficeBadge();
showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`); showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`);
// Anonymous/self-host-only behavior ends here exactly as before. The
// transport chunk is fetched only for a signed-in explicit share.
if (access.subject !== null) void startRemotePresenter(surface, shared);
} catch (error) { } catch (error) {
for (const track of pendingStream?.getTracks() ?? []) track.stop(); for (const track of pendingStream?.getTracks() ?? []) track.stop();
pendingTexture?.dispose(); pendingTexture?.dispose();
@@ -2362,9 +2574,28 @@ function ensureOfficeScreenPanel(): OfficeScreenPanel | null {
container: screensOverlay, container: screensOverlay,
surfaces: office.listMediaSurfaces(), surfaces: office.listMediaSurfaces(),
onRequestShare: (surface) => { void startLocalScreenShare(surface); }, onRequestShare: (surface) => { 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) { 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(() => { const syncOverlay = () => queueMicrotask(() => {
+90
View File
@@ -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<EphemeralIceConfiguration> {
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,
};
}
+42
View File
@@ -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<T> =
| { ok: true; value: T }
| { ok: false; error: string };
+123
View File
@@ -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<string, unknown>;
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<IceConfigRequest> {
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<IceConfigResponse> {
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<T>(value: T): IceConfigValidationResult<T> { return { ok: true, value }; }
function failure<T>(error: string): IceConfigValidationResult<T> { return { ok: false, error: `ice config: ${error}` }; }
+14
View File
@@ -21,6 +21,7 @@ export {
createOfficeScreenPanel, createOfficeScreenPanel,
type OfficeScreenPanel, type OfficeScreenPanel,
type OfficeScreenPanelOptions, type OfficeScreenPanelOptions,
type OfficeScreenRemoteStatus,
type OfficeScreenPanelState, type OfficeScreenPanelState,
} from "./officeScreenPanel.ts"; } from "./officeScreenPanel.ts";
export { export {
@@ -43,3 +44,16 @@ export {
parseScreenShareClientMessage, parseScreenShareClientMessage,
parseScreenShareServerMessage, parseScreenShareServerMessage,
} from "./signalingValidation.ts"; } 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";
+50 -6
View File
@@ -2,6 +2,8 @@
import type { MediaSurfaceDescriptor } from "./presentation.ts"; import type { MediaSurfaceDescriptor } from "./presentation.ts";
export type OfficeScreenRemoteStatus = "off" | "connecting" | "live" | "reconnecting" | "stopped" | "unavailable" | "error";
export interface OfficeScreenPanelOptions { export interface OfficeScreenPanelOptions {
container: HTMLElement; container: HTMLElement;
surfaces: readonly MediaSurfaceDescriptor[]; surfaces: readonly MediaSurfaceDescriptor[];
@@ -19,6 +21,7 @@ export interface OfficeScreenPanelState {
/** Explicit, local viewing choices. Empty initially and after disposal. */ /** Explicit, local viewing choices. Empty initially and after disposal. */
optedInScreenIds: string[]; optedInScreenIds: string[];
surfaces: MediaSurfaceDescriptor[]; surfaces: MediaSurfaceDescriptor[];
remoteStatusByScreen: Record<string, OfficeScreenRemoteStatus>;
} }
export interface OfficeScreenPanel { export interface OfficeScreenPanel {
@@ -27,6 +30,7 @@ export interface OfficeScreenPanel {
update(surfaces: readonly MediaSurfaceDescriptor[]): OfficeScreenPanelState; update(surfaces: readonly MediaSurfaceDescriptor[]): OfficeScreenPanelState;
close(): OfficeScreenPanelState; close(): OfficeScreenPanelState;
state(): OfficeScreenPanelState; state(): OfficeScreenPanelState;
setRemoteStatus(screenId: string, status: OfficeScreenRemoteStatus): OfficeScreenPanelState;
dispose(): void; dispose(): void;
} }
@@ -53,6 +57,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
let surfaces = validateSurfaces(options.surfaces); let surfaces = validateSurfaces(options.surfaces);
let selectedId: string | null = surfaces[0]?.screenId ?? null; let selectedId: string | null = surfaces[0]?.screenId ?? null;
const optedIn = new Set<string>(); const optedIn = new Set<string>();
const remoteStatuses = new Map<string, OfficeScreenRemoteStatus>();
let isOpen = false; let isOpen = false;
let disposed = false; let disposed = false;
let invoker: HTMLElement | null = null; let invoker: HTMLElement | null = null;
@@ -79,7 +84,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
const intro = doc.createElement("p"); const intro = doc.createElement("p");
intro.id = introId; intro.id = introId;
intro.className = "tera-screen-panel__intro"; 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"); const list = doc.createElement("div");
list.className = "tera-screen-panel__list"; list.className = "tera-screen-panel__list";
list.setAttribute("role", "list"); list.setAttribute("role", "list");
@@ -97,6 +102,11 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
return surfaces.find((surface) => surface.screenId === selectedId) ?? null; 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 { function render(): void {
for (const child of [...list.children]) child.remove(); for (const child of [...list.children]) child.remove();
if (surfaces.length === 0) { if (surfaces.length === 0) {
@@ -117,7 +127,15 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
meta.className = "tera-screen-panel__meta"; meta.className = "tera-screen-panel__meta";
const room = surface.roomId ?? "Unassigned room"; const room = surface.roomId ?? "Unassigned room";
const status = surface.bound ? "Media active" : "Media off"; 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.append(name, meta);
row.addEventListener("click", () => { row.addEventListener("click", () => {
selectedId = surface.screenId; selectedId = surface.screenId;
@@ -129,15 +147,26 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
const surface = selected(); const surface = selected();
const viewing = surface ? optedIn.has(surface.screenId) : false; const viewing = surface ? optedIn.has(surface.screenId) : false;
optButton.disabled = surface === null; optButton.disabled = surface === null;
shareButton.disabled = surface === null || !viewing; // Presenting is its own explicit action. Requiring viewer opt-in here would
stopButton.disabled = surface === null || !surface.bound; // start a remote viewer join immediately before replacing it with capture.
optButton.textContent = viewing ? "Stop viewing" : "Opt in to view"; 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.setAttribute("aria-pressed", String(viewing));
} }
optButton.addEventListener("click", () => { optButton.addEventListener("click", () => {
const surface = selected(); const surface = selected();
if (!surface) return; 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); const next = !optedIn.has(surface.screenId);
if (next) optedIn.add(surface.screenId); if (next) optedIn.add(surface.screenId);
else optedIn.delete(surface.screenId); else optedIn.delete(surface.screenId);
@@ -150,7 +179,9 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
}); });
stopButton.addEventListener("click", () => { stopButton.addEventListener("click", () => {
const surface = selected(); 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()); closeButton.addEventListener("click", () => close());
@@ -160,6 +191,7 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
selectedId, selectedId,
optedInScreenIds: [...optedIn], optedInScreenIds: [...optedIn],
surfaces: surfaces.map(copySurface), surfaces: surfaces.map(copySurface),
remoteStatusByScreen: Object.fromEntries(remoteStatuses),
}; };
} }
@@ -221,17 +253,29 @@ export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): Offi
surfaces = validateSurfaces(next); surfaces = validateSurfaces(next);
const ids = new Set(surfaces.map((surface) => surface.screenId)); 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 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; if (selectedId === null || !ids.has(selectedId)) selectedId = surfaces[0]?.screenId ?? null;
render(); render();
return snapshot(); return snapshot();
}, },
close, close,
state: snapshot, 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() { dispose() {
if (disposed) return; if (disposed) return;
close(); close();
disposed = true; disposed = true;
optedIn.clear(); optedIn.clear();
remoteStatuses.clear();
surfaces = []; surfaces = [];
selectedId = null; selectedId = null;
root.remove(); root.remove();
+28 -6
View File
@@ -60,11 +60,22 @@ export interface PresenterStart {
stream: MediaStream; stream: MediaStream;
video: HTMLVideoElement; video: HTMLVideoElement;
} }
export interface ViewerStart { export type ViewerStart =
| {
/** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */ /** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */
decision: MediaAuthorizationDecision; decision: MediaAuthorizationDecision;
viewerOptIn?: never;
video: HTMLVideoElement; 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 { export interface RemoteOfficeMedia {
startPresenter(input: PresenterStart): Promise<void>; startPresenter(input: PresenterStart): Promise<void>;
startViewer(input: ViewerStart): Promise<void>; startViewer(input: ViewerStart): Promise<void>;
@@ -310,6 +321,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
cursor = advanced.value; cursor = advanced.value;
if (message.type === "screen-share-stopped") { if (message.type === "screen-share-stopped") {
closeTransport(); 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"); setStatus("stopped");
return; return;
} }
@@ -412,7 +427,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
if (!message.continuous) resetPeerMesh(); if (!message.continuous) resetPeerMesh();
await reconcile(message.participants); await reconcile(message.participants);
scheduleRenewal(); 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; return true;
} }
@@ -492,6 +510,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs }; cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs };
await reconcile(response.participants); await reconcile(response.participants);
scheduleRenewal(); scheduleRenewal();
if (role === "presenter" && peers.size === 0) setStatus("live");
void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media events failed")); scheduleReconnect(); }); 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<void> { async function startViewer(input: ViewerStart): Promise<void> {
if (options.role !== "viewer") throw new Error("remote media: adapter is not a viewer"); 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 (status === "disposed" || status === "revoked") throw new Error("remote media: session is terminal");
if (!input.decision.authorized || !input.decision.optedIn || !input.decision.canView || const ready = input.decision === undefined
input.decision.surface.screenId !== binding.screenId || input.decision.surface.officeId !== binding.officeId || ? input.viewerOptIn === true
input.decision.surface.source?.kind !== "live-stream") { : 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"); throw new Error("remote media: a ready live-stream authorization decision is required");
} }
input.video.autoplay = false; input.video.autoplay = false;
+124
View File
@@ -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\)/);
});
});
+67
View File
@@ -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<string, unknown>;
assert.equal(parseIceConfigResponse({
...grant(), iceServers: [{ ...turn, urls: ["turn:user@relay.example.test:3478"] }],
}).ok, false);
});
});
+47 -1
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; 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; type Listener = (event: FakeEvent) => void;
class FakeEvent { class FakeEvent {
@@ -121,6 +122,18 @@ describe("office screen manager panel", () => {
assert.equal("mediaDevices" in panel, false); 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", () => { it("updates defensively, removes stale consent, and represents an empty office", () => {
const { panel } = setup(); const { panel } = setup();
const root = panel.root as unknown as FakeElement; 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/); 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", () => { it("traps tab focus, closes on Escape, restores focus, and disposes idempotently", () => {
const { document, container, panel } = setup(); const { document, container, panel } = setup();
const trigger = document.createElement("button"); const trigger = document.createElement("button");
+37 -1
View File
@@ -182,6 +182,7 @@ describe("remote office media presenter", () => {
/explicit opt-in/, /explicit opt-in/,
); );
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: stream as unknown as MediaStream, video: video as unknown as HTMLVideoElement }); 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(); await settle();
assert.equal(peers.length, 1); assert.equal(peers.length, 1);
assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack); assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack);
@@ -324,7 +325,7 @@ describe("remote office media viewer", () => {
}); });
const video = new FakeVideo(); const video = new FakeVideo();
await assert.rejects(media.startViewer({ decision: decision(false), video: video as unknown as HTMLVideoElement }), /ready live-stream/); 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(); await settle();
assert.equal(peers[0]?.transceivers[0], "video"); assert.equal(peers[0]?.transceivers[0], "video");
assert.deepEqual(peers[0]?.remote[0], { type: "offer", sdp: "presenter-offer" }); 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(video.srcObject, null);
assert.equal(media.state().status, "revoked"); 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<string, unknown>;
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<Uint8Array>({ 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");
});
}); });