feat: stream private office screens
This commit is contained in:
@@ -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=()"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# TURN for office screen sharing
|
||||||
|
|
||||||
|
This directory is an **operator-reviewed template**, not an installer. Nothing
|
||||||
|
here changes DNS, Caddy, systemd, UFW or Oracle Cloud. Those are public routing
|
||||||
|
changes and require explicit operator approval on the deployment host.
|
||||||
|
|
||||||
|
Tera's browser peers normally connect directly. Coturn is the fallback for
|
||||||
|
symmetric NATs and restrictive networks; it relays encrypted WebRTC packets and
|
||||||
|
never receives application signaling, screen URLs or recordings. The signaling
|
||||||
|
service remains the separate loopback API described in `server/README.md`.
|
||||||
|
|
||||||
|
> **Mandatory public-enablement gate:** do not expose the TURN listeners or set
|
||||||
|
> `TERA_ICE_URLS` / `TERA_TURN_SHARED_SECRET` in production until the deployed
|
||||||
|
> ICE endpoint requires an active, unexpired screen-signaling grant for the exact
|
||||||
|
> office/screen binding and authenticates that grant to the same signed-in
|
||||||
|
> subject. The current endpoint checks only global member authentication, which
|
||||||
|
> would let any signed-in member mint a general-purpose Internet relay credential.
|
||||||
|
> It is intentionally not approved for broad public TURN enablement.
|
||||||
|
|
||||||
|
The production host audited on 2026-08-11 has private VNIC `10.0.0.2`, public
|
||||||
|
IPv4 `170.9.14.61`, and an existing `turn.lumbridgecorp.com` A record. Reconfirm
|
||||||
|
all three immediately before deployment. Do not copy those addresses into a
|
||||||
|
different installation merely because they appear here.
|
||||||
|
|
||||||
|
## 1. Review the network boundary
|
||||||
|
|
||||||
|
The intended public listeners are:
|
||||||
|
|
||||||
|
| Transport | Port | Purpose |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| UDP | 3478 | primary STUN/TURN listener |
|
||||||
|
| TCP | 3478 | TURN fallback |
|
||||||
|
| TCP/TLS | 5349 | TURN/TLS fallback |
|
||||||
|
| UDP | 52000–53023 | dedicated relay allocations |
|
||||||
|
|
||||||
|
Open the same ingress in **both** UFW and the instance's OCI security list or
|
||||||
|
NSG. The OCI console is authoritative; a host firewall rule does not prove the
|
||||||
|
cloud edge permits traffic. Relay UDP must accept arbitrary Internet peers, not
|
||||||
|
only the browser that created an allocation. Outbound traffic remains allowed.
|
||||||
|
|
||||||
|
Example commands to review, not paste blindly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short A turn.lumbridgecorp.com
|
||||||
|
ip -br address
|
||||||
|
curl -4 https://api.ipify.org
|
||||||
|
sudo ufw status verbose
|
||||||
|
|
||||||
|
# Only after the operator approves these public ingress changes:
|
||||||
|
sudo ufw allow 3478/udp comment 'Tera TURN UDP'
|
||||||
|
sudo ufw allow 3478/tcp comment 'Tera TURN TCP'
|
||||||
|
sudo ufw allow 5349/tcp comment 'Tera TURN TLS'
|
||||||
|
sudo ufw allow 52000:53023/udp comment 'Tera TURN relay'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add equivalent stateful OCI ingress rules. Do not open UDP 5349: this template
|
||||||
|
disables DTLS. Do not reuse UDP 50000–50200; stopped LiveKit deployments on the
|
||||||
|
audited host reserve that range and could otherwise collide if restarted.
|
||||||
|
|
||||||
|
The public/private NAT must preserve relay port numbers. This is why the config
|
||||||
|
uses `external-ip=PUBLIC/PRIVATE`; a relay candidate advertising `10.0.0.2` or a
|
||||||
|
different public port is a failed deployment.
|
||||||
|
|
||||||
|
## 2. Install and render the config
|
||||||
|
|
||||||
|
Prefer Ubuntu's native coturn package and unit. Docker adds no isolation benefit
|
||||||
|
to a service that needs a large host UDP range, and bridged port translation is
|
||||||
|
an extra failure mode. Inspect the package before enabling it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt-cache policy coturn
|
||||||
|
systemctl cat coturn.service
|
||||||
|
getent passwd turnserver
|
||||||
|
getent group turnserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Some distro packages also ship `/etc/default/coturn` with an explicit enable
|
||||||
|
switch. Inspect it and the unit's conditions; set `TURNSERVER_ENABLED=1` only as
|
||||||
|
part of the reviewed enablement, never by assuming a successful `systemctl`
|
||||||
|
command means the daemon actually bound its sockets.
|
||||||
|
|
||||||
|
Generate one 256-bit hexadecimal secret without putting its value in shell
|
||||||
|
history:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
umask 077
|
||||||
|
TURN_SECRET="$(openssl rand -hex 32)"
|
||||||
|
test "${#TURN_SECRET}" -eq 64
|
||||||
|
```
|
||||||
|
|
||||||
|
Render `turnserver.conf.example` to a root-owned `/etc/turnserver.conf`, replacing
|
||||||
|
the two IP placeholders and the secret placeholder. The installed file should
|
||||||
|
be `root:turnserver` mode `0640`. Put the **same raw secret** in the Tera API's
|
||||||
|
root-owned environment file:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
TERA_TURN_SHARED_SECRET=<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)
|
||||||
Executable
+18
@@ -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
|
||||||
Executable
+29
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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, 32–4096 bytes. Never expose this in the static build. |
|
||||||
|
| `TERA_TURN_CREDENTIAL_TTL` | `300` | Credential lifetime in seconds, bounded to 60–3600. |
|
||||||
|
| `TERA_ICE_RATE_ATTEMPTS` | `30` | Maximum grants per trusted client address in one rate window. |
|
||||||
|
| `TERA_ICE_RATE_WINDOW` | `60` | Rate window in seconds, bounded to 1–3600. |
|
||||||
|
|
||||||
A self-hoster gets `none`, an open office, and never creates an account
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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}` }; }
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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. */
|
| {
|
||||||
decision: MediaAuthorizationDecision;
|
/** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */
|
||||||
video: HTMLVideoElement;
|
decision: MediaAuthorizationDecision;
|
||||||
}
|
viewerOptIn?: never;
|
||||||
|
video: HTMLVideoElement;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* Literal user action for signaling servers that perform authorization
|
||||||
|
* during join and intentionally expose no source locator to the client.
|
||||||
|
*/
|
||||||
|
decision?: never;
|
||||||
|
viewerOptIn: true;
|
||||||
|
video: HTMLVideoElement;
|
||||||
|
};
|
||||||
export interface RemoteOfficeMedia {
|
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;
|
||||||
|
|||||||
@@ -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\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user