diff --git a/.github/workflows/join.yml b/.github/workflows/join.yml index 4e21a04..0a35c55 100644 --- a/.github/workflows/join.yml +++ b/.github/workflows/join.yml @@ -13,6 +13,9 @@ on: - "js/join/**" - "package.json" - "bun.lock" + - "bunfig.toml" + # The join page builds on the submodule's JS packages. + - "moq" - "flake.lock" - ".github/workflows/join.yml" workflow_dispatch: @@ -34,6 +37,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + submodules: true - uses: ./.github/actions/nix diff --git a/Cargo.lock b/Cargo.lock index 68e54ce..0ac89ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3844,8 +3844,10 @@ dependencies = [ "mp4-atom", "pollster", "qrcodegen", + "serde", "tempfile", "tokio", + "toml", "tracing", "url", "usage-rs", diff --git a/README.md b/README.md index 0c6c3e4..a810125 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,11 @@ and their camera and screen appear as broadcast sources. The room follows named `alice` publishes `alice/camera.hang` and `alice/screen.hang` under it. Guests need a relay that verifies tokens, so run your own with [`moq auth serve`](https://doc.moq.dev/bin/cli) behind it, and a key that can -sign only for the room: +sign only for the room. The relay must be built from moq's `main` and accept +moq-lite-07 (`--listen-version moq-lite-07-wip`, beside the versions your +other clients use), the version that renews a guest's token without +reconnecting. Its wire is still a work in progress, so the join page offers +nothing else, and a guest cannot join through a relay without it. ```bash moq auth generate --algorithm ES256 --out room.jwk --public-dir keys \ @@ -176,8 +180,21 @@ moq-studio show.toml --connect 'https://relay.example.com/show?jwt=...' \ `localhost`, so the studio refuses any other. It defaults to `https://join.studio.moq.dev/`, which this repository deploys from `main`, but that domain is not live yet, so for now host the page yourself and pass its URL. -- `--guest-expiry` (default `4h`) is how long a link works. The relay - disconnects a guest when their link expires, so send a new one before then. +- `--guest-expiry` (default `4h`, at least `1m`) is how long a link works, + and each token the studio renews it with. While a guest is in the room, the + studio signs them a fresh token every half of it, over a session of its own + in the room that the room key signs for, and the join page presents it + without reconnecting. A guest's token may read only their own renewals, at + `.renew//` in the room, but anything else the relay lets subscribe + there can read every guest's, so grant that only to the studio. A guest who + leaves needs a link, or a renewed token, that has not expired to come back. + The Guests panel says when renewal is failing. +- **Upgrading from a studio without renewal:** generate a new room key, drop + the old one's public half from `--key-dir`, then restart the relay (or wait + out the old links' `--guest-expiry`) before starting the upgraded studio. + Links from before let their holder subscribe to the whole room, renewals + included, so they must die with the old key, and a relay keeps a session + it already admitted until its token expires. - With `--failover`, a link still names only the `--connect` relay: the join page dials one relay, and a guest's token is rooted at the room there, so guests drop while that relay is down. The studio follows the room through @@ -189,9 +206,11 @@ moq-studio show.toml --connect 'https://relay.example.com/show?jwt=...' \ **Invite** in the Guests panel makes a link for one guest name and shows it as a QR code; **Copy link** puts it on the clipboard instead. The guest's token is in the link's fragment, which the browser never sends to a server, and lets -them publish only under their own name, so they cannot appear as someone else. -Treat the link like a password: anyone holding it can join as that guest until -it expires. A guest's broadcasts become sources named after them (`alice`, +them publish only under their own name, so they cannot appear as someone else, +and read only their own renewals. +Treat the link like a password: anyone holding it can join as that guest +before it expires, and is then renewed for as long as they stay, until you +remove them. A guest's broadcasts become sources named after them (`alice`, `alice screen`) when they first appear, muted and in no scene: place them from the Items panel and unmute them in Audio to put them on air. That includes a guest invited before a restart whose link still works. A guest's source is @@ -201,8 +220,13 @@ who reconnects after you removed their source comes back under a new name (`alice 2`), since undoing the removal brings back the old one. **Remove from scenes** is your edit, so it undoes: it takes a guest, any clone of their sources, and any mask reading them out of every scene and the top -layer and mutes them; a signed token cannot be taken back, so they stay -connected until it expires. +layer and mutes them. A signed token cannot be taken back, so it also stops +renewing theirs, and they are disconnected within `--guest-expiry`. That +holds across a restart, whichever scene file you open: removed guests are +kept beside the room key, as `room.guests.toml` for `room.jwk`, so its +directory must be writable. Undo puts them back in the scenes but not on +renewal; **New link** lets them back in and renews them again, under a new +generation, so a link from before the removal cannot read their renewals. ## Scene file @@ -508,7 +532,9 @@ just join # Serve the join page locally ``` `rs/moq-scene` is the UI-free compositor, `rs/moq-studio` the app, and -`js/join` the join page, built on `@moq/room` and `@moq/publish`. See +`js/join` the join page, built on `@moq/room` and `@moq/publish`. Like the +Rust crates, those come from the `moq` submodule rather than npm, so +`bun install` needs it checked out, and Nix to build `@moq/libav`. See [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md), and the [quest tree](quest/README.md) for planned work. diff --git a/bun.lock b/bun.lock index c6b4854..e402ed0 100644 --- a/bun.lock +++ b/bun.lock @@ -12,13 +12,255 @@ "name": "@moq/studio-join", "version": "0.0.0", "dependencies": { - "@moq/net": "^0.4.7", - "@moq/publish": "^0.5.4", - "@moq/room": "^0.2.1", - "@moq/signals": "^0.2.6", + "@moq/net": "workspace:*", + "@moq/publish": "workspace:*", + "@moq/room": "workspace:*", + "@moq/signals": "workspace:*", }, "devDependencies": { "@types/bun": "^1.4.4", + "rolldown": "^1.2.15", + "typescript": "7.0.2", + "vite": "^8.3.5", + }, + }, + "moq/js/clock": { + "name": "@moq/clock", + "version": "0.1.0", + "devDependencies": { + "@types/bun": "^1.4.4", + "typescript": "7.0.2", + }, + }, + "moq/js/hang": { + "name": "@moq/hang", + "version": "0.5.2", + "dependencies": { + "@kixelated/libavjs-webcodecs-polyfill": "^0.5.6", + "@moq/clock": "workspace:^", + "@moq/json": "workspace:^", + "@moq/libav": "workspace:^", + "@moq/loc": "workspace:^", + "@moq/net": "workspace:^", + "@moq/signals": "workspace:^", + "zod": "^4.6.7", + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "typescript": "7.0.2", + }, + }, + "moq/js/json": { + "name": "@moq/json", + "version": "0.4.2", + "dependencies": { + "@moq/net": "workspace:^", + "@moq/signals": "workspace:^", + }, + "devDependencies": { + "@moq/clock": "workspace:^", + "@types/bun": "^1.4.4", + "fast-check": "^4.10.2", + "typescript": "7.0.2", + }, + "peerDependencies": { + "zod": "^4.5.0", + }, + }, + "moq/js/libav": { + "name": "@moq/libav", + "version": "0.1.0", + "dependencies": { + "@libav.js/types": "^6.10.9", + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "typescript": "7.0.2", + }, + }, + "moq/js/loc": { + "name": "@moq/loc", + "version": "0.2.4", + "dependencies": { + "@moq/clock": "workspace:^", + "@moq/net": "workspace:^", + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "typescript": "7.0.2", + }, + }, + "moq/js/msf": { + "name": "@moq/msf", + "version": "0.3.1", + "dependencies": { + "@moq/json": "workspace:^", + "@moq/net": "workspace:^", + "zod": "^4.6.7", + }, + "devDependencies": { + "typescript": "7.0.2", + }, + }, + "moq/js/net": { + "name": "@moq/net", + "version": "0.4.2", + "dependencies": { + "@moq/clock": "workspace:^", + "@moq/pattern": "workspace:^", + "@moq/qmux": "workspace:^", + "@moq/signals": "workspace:*", + "pako": "^3.0.3", + "sorted-btree": "^2.1.0", + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "@types/node": "^26.6.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "fast-check": "^4.10.2", + "typescript": "7.0.2", + }, + "peerDependencies": { + "zod": "^4.5.0", + }, + }, + "moq/js/pattern": { + "name": "@moq/pattern", + "version": "0.2.1", + "devDependencies": { + "@types/bun": "^1.4.4", + "typescript": "7.0.2", + }, + }, + "moq/js/publish": { + "name": "@moq/publish", + "version": "0.5.2", + "dependencies": { + "@moq/clock": "workspace:^", + "@moq/hang": "workspace:^", + "@moq/json": "workspace:^", + "@moq/net": "workspace:^", + "@moq/signals": "workspace:^", + "@moq/ui": "workspace:^", + "@moq/video": "workspace:^", + "mediabunny": "^1.62.1", + }, + "devDependencies": { + "@types/audioworklet": "^0.0.100", + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "rolldown": "^1.2.15", + "typescript": "7.0.2", + "vite": "^8.3.5", + }, + }, + "moq/js/qmux": { + "name": "@moq/qmux", + "version": "0.3.3", + "dependencies": { + "@moq/clock": "workspace:^", + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "typescript": "7.0.2", + }, + }, + "moq/js/room": { + "name": "@moq/room", + "version": "0.2.1", + "dependencies": { + "@moq/clock": "workspace:^", + "@moq/hang": "workspace:^", + "@moq/json": "workspace:^", + "@moq/net": "workspace:^", + "@moq/publish": "workspace:^", + "@moq/signals": "workspace:^", + "@moq/watch": "workspace:^", + "zod": "^4.6.7", + }, + "devDependencies": { + "@types/audioworklet": "^0.0.100", + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "typescript": "7.0.2", + "vite": "^8.3.5", + }, + }, + "moq/js/signals": { + "name": "@moq/signals", + "version": "0.2.5", + "dependencies": { + "@moq/clock": "workspace:^", + }, + "devDependencies": { + "@happy-dom/global-registrator": "^21.0.4", + "@types/bun": "^1.4.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "fast-check": "^4.10.2", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "solid-js": "^1.9.17", + "typescript": "7.0.2", + }, + "peerDependencies": { + "@types/react": "^19.2.17", + "react": "^19.3.1", + "solid-js": "^1.9.15", + }, + "optionalPeers": [ + "@types/react", + "react", + "solid-js", + ], + }, + "moq/js/ui": { + "name": "@moq/ui", + "version": "0.1.0", + "dependencies": { + "@moq/signals": "workspace:^", + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "typescript": "7.0.2", + "vite": "^8.3.5", + }, + }, + "moq/js/video": { + "name": "@moq/video", + "version": "0.1.0", + "dependencies": { + "@moq/signals": "workspace:^", + }, + "devDependencies": { + "@moq/clock": "workspace:^", + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "typescript": "7.0.2", + }, + }, + "moq/js/watch": { + "name": "@moq/watch", + "version": "0.6.2", + "dependencies": { + "@moq/clock": "workspace:^", + "@moq/hang": "workspace:^", + "@moq/json": "workspace:^", + "@moq/msf": "workspace:^", + "@moq/net": "workspace:^", + "@moq/signals": "workspace:^", + "@moq/ui": "workspace:^", + "@moq/video": "workspace:^", + "media-captions": "0.0.18", + }, + "devDependencies": { + "@types/audioworklet": "^0.0.100", + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "rolldown": "^1.2.15", "typescript": "7.0.2", "vite": "^8.3.5", }, @@ -47,39 +289,43 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.6.4", "", { "os": "win32", "cpu": "x64" }, "sha512-pYsMy5AThiHFOZAsngg8qN4PurM6nInKBhVX7sbj3eNpRQqo1Hb0MgCwPL7VqVMrmcEEMj9Ks3qbkallR65e8g=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^21.0.4" } }, "sha512-Bbr0hW9hhDhBf3Jp0EyDlkCbFgbr0z2jd3BMKYOYW+fOkPM4Nx195GMXyCtRCGEgoNn6i09FabXrSkNFm2ONzg=="], + "@kixelated/libavjs-webcodecs-polyfill": ["@kixelated/libavjs-webcodecs-polyfill@0.5.6", "", { "dependencies": { "@libav.js/types": "^6.7.7", "@ungap/global-this": "^0.4.4" } }, "sha512-A6FBsEAT/t0cDDaxo3h5XabiHX2RtycQbWV+MNKUghmctrXU1ygl05i3TTtnSF/eLmCLLO8xBMrXO+eGnpLWFw=="], "@libav.js/types": ["@libav.js/types@6.10.9", "", {}, "sha512-AdngxD/VO8OI6/w7wlJC77kuFcsSvCUuo2RuC6gGeSW2DEvbzgBo2JtFLV6UWZp/tRNZhKm6jS0Tv8gwCKBHLg=="], - "@libav.js/variant-opus-af": ["@libav.js/variant-opus-af@6.10.9", "", {}, "sha512-yULJUCQIHDtwDPbT38t9pP/7EGgqwynVcOau46hZjtCe8TWMp67DTi2X0Emr2M4Ci3nzxNPf25IzO+IXSmHDBg=="], + "@moq/clock": ["@moq/clock@workspace:moq/js/clock"], - "@moq/flate": ["@moq/flate@0.1.4", "", { "dependencies": { "pako": "^3.0.2" } }, "sha512-o+rQuNeHpNC81oLX38DV4HwmZOTJqjvkluNh3Z/qOJ+kpqDAB8rOS9ge1qSk6q399XC9gwBzbWE0RIyWUfWwpQ=="], + "@moq/hang": ["@moq/hang@workspace:moq/js/hang"], - "@moq/hang": ["@moq/hang@0.5.4", "", { "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.10.9", "@moq/json": "^0.4.3", "@moq/loc": "^0.2.4", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6", "@svta/cml-iso-bmff": "^1.0.7", "@svta/cml-utils": "^1.6.2", "@zod/mini": "^4.6.5", "zod": "^4.6.5" } }, "sha512-oHRqFBF1P5O1TVpFmxqWRM6Nkltnu8jzvybk/nIBSyMbUYeFWbbiWaWNDxXu3jeXX7ohm01oh5kkrwT/+bQ1uw=="], + "@moq/json": ["@moq/json@workspace:moq/js/json"], - "@moq/json": ["@moq/json@0.4.3", "", { "dependencies": { "@moq/flate": "^0.1.3", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6" }, "peerDependencies": { "@zod/mini": "^4.5.0", "zod": "^4.5.0" } }, "sha512-HTfLRnuvA5sWvLzXjsfAfrdAUJiLG8FyiWUGmWR598kPq+S2SNJ3gK1uoUWeb/+z/fgEY/kdrg1BHv+TmAz/3g=="], + "@moq/libav": ["@moq/libav@workspace:moq/js/libav"], - "@moq/loc": ["@moq/loc@0.2.4", "", { "dependencies": { "@moq/net": "^0.4.2" } }, "sha512-cwe0y+j7mfIuC+DOrfdLB1k5YU2+/xIfhnTv8VEiKLM5ZyfOZHblK3UwnrKqChcKkGFt3FkedRWFLMDIt9GCuA=="], + "@moq/loc": ["@moq/loc@workspace:moq/js/loc"], - "@moq/msf": ["@moq/msf@0.3.1", "", { "dependencies": { "@moq/net": "^0.4.2", "@zod/mini": "^4.6.5", "zod": "^4.6.5" } }, "sha512-ZM9aZEtK/3dudeM2t5N9lujIpqEeDDOi6uHQp63mYOsBLlEUFEKff6y6WLicJJxGT9BM3h0mxh1Sv9YBT6jceQ=="], + "@moq/msf": ["@moq/msf@workspace:moq/js/msf"], - "@moq/net": ["@moq/net@0.4.7", "", { "dependencies": { "@moq/pattern": "^0.2.1", "@moq/qmux": "^0.3.3", "@moq/signals": "^0.2.6", "async-mutex": "^0.5.0", "bowser": "^2.14.1" }, "peerDependencies": { "@zod/mini": "^4.5.0", "zod": "^4.5.0" } }, "sha512-klFav+4XZjR1GsgRJr6jNt1BuBNGc01+AivI8a1f/pBeKNcqL7t9k9xn5RP78Bprnf4H7BUgTi14vvexPCuTNA=="], + "@moq/net": ["@moq/net@workspace:moq/js/net"], - "@moq/pattern": ["@moq/pattern@0.2.1", "", {}, "sha512-NXlegs0abkYoIUkcxyJjnUHC1CGw8VkooAbuNKLK5AF0hCAyUWw1QNliHaLBty4K76qfrrvJK6cf7j31Z0KF0A=="], + "@moq/pattern": ["@moq/pattern@workspace:moq/js/pattern"], - "@moq/publish": ["@moq/publish@0.5.4", "", { "dependencies": { "@moq/hang": "^0.5.4", "@moq/json": "^0.4.3", "@moq/net": "^0.4.7", "@moq/signals": "^0.2.6", "mediabunny": "^1.58.1" } }, "sha512-uOMShdgkpXZ4WNjD8IDPlbrXl6UB69IeT3OrcDzX45nAJxuzNKJsOMwh7XPxa2/2Kr6fmV71XxUGb1ap74GHjg=="], + "@moq/publish": ["@moq/publish@workspace:moq/js/publish"], - "@moq/qmux": ["@moq/qmux@0.3.4", "", { "dependencies": { "@moq/web-socket-stream": "^0.1.1" } }, "sha512-6XxjqsK1bi2bdIZ7+g8gkHh3v6NOEArqb6IMtHokZI+wP4Y7OB45zHrxUe7v42pcf08RnfqfTkphxHgZfVLyPw=="], + "@moq/qmux": ["@moq/qmux@workspace:moq/js/qmux"], - "@moq/room": ["@moq/room@0.2.1", "", { "dependencies": { "@moq/hang": "^0.5.1", "@moq/json": "^0.4.1", "@moq/net": "^0.4.1", "@moq/publish": "^0.5.1", "@moq/signals": "^0.2.5", "@moq/watch": "^0.6.1" } }, "sha512-QEGqpcCF0x1zZFg1FYxny/NfMc7bFCY2lqAgjfD+NgKIf7xgbi/YGziUwynkhciTBZUdfHDD1FoJNlWxhdgmVA=="], + "@moq/room": ["@moq/room@workspace:moq/js/room"], - "@moq/signals": ["@moq/signals@0.2.6", "", { "peerDependencies": { "@types/react": "^19.2.17", "react": "^19.3.0", "solid-js": "^1.9.15" }, "optionalPeers": ["@types/react", "react", "solid-js"] }, "sha512-8L5y9cJSOVYGrHCZ20kcZsrmoxVXzl2dBQhsRj8H4zbbul70SmZcdeLG+O3JBP5W3tjo8EmLR+DOmh1/p7QQYw=="], + "@moq/signals": ["@moq/signals@workspace:moq/js/signals"], "@moq/studio-join": ["@moq/studio-join@workspace:js/join"], - "@moq/watch": ["@moq/watch@0.6.6", "", { "dependencies": { "@moq/hang": "^0.5.4", "@moq/json": "^0.4.3", "@moq/msf": "^0.3.1", "@moq/net": "^0.4.5", "@moq/signals": "^0.2.6", "media-captions": "0.0.18" } }, "sha512-wtUoblpIS/UTgrW/IeQ6dlsZJDVo5nAbzU8oJWDU4K6JSiKyD1b08p19LPloeRJ/UPpA17r1PM4l35IOuoMXdg=="], + "@moq/ui": ["@moq/ui@workspace:moq/js/ui"], - "@moq/web-socket-stream": ["@moq/web-socket-stream@0.1.1", "", {}, "sha512-VKi/GE/CLqmbEZlgNbBLbAFk2pQTxT+KUD2DjCSoxqZxbS3KBe5BBZ0m3fila4fEGyll2JT5MmgEyVr4zkZEVA=="], + "@moq/video": ["@moq/video@workspace:moq/js/video"], + + "@moq/watch": ["@moq/watch@workspace:moq/js/watch"], "@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="], @@ -115,9 +361,7 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.7", "", { "peerDependencies": { "@svta/cml-utils": "^1.6.2" } }, "sha512-pYM60+pcGd8G8USeVKOe4X25FdmKd4ihTLQWRszxSE/E0I/SFugqeexdlXyBiJbOhkzc8PFceIWv+RvrB9+NDw=="], - - "@svta/cml-utils": ["@svta/cml-utils@1.7.0", "", {}, "sha512-NNa9v5CxrmULRAu5zx1ENHVwF4xKfclEdm+OmaIZDwoTNLVpf7JGgwbxkYsbRQYPCZrQTTLlR4sVjwgS6gcmug=="], + "@types/audioworklet": ["@types/audioworklet@0.0.100", "", {}, "sha512-mWt4Z5CWi0CupDnSYWJgx6dw61XUk9JSRGrM9F/YNrpOoY/0XG+sxP+8rAjQq80H4febOe8PrzGmmN/X3w8LsQ=="], "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], @@ -127,6 +371,16 @@ "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + + "@typescript/lib-dom": ["@types/web@0.0.358", "", {}, "sha512-mNaTgu3PxD0NY0QTpMSFcs+BurApADI/h0oK/sKzkzobGw9F/GpAM4x3Vreul/EAsgm5I8uKzMnuSazZ1qHjgw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], @@ -169,20 +423,24 @@ "@ungap/global-this": ["@ungap/global-this@0.4.4", "", {}, "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA=="], - "@zod/mini": ["@zod/mini@4.6.7", "", { "peerDependencies": { "zod": "^4.6.0" } }, "sha512-oLORzy4OERpr+gaITCsrCK9uDWd9lOCLG00fRZEJidVITLS5SQtifmzP6fBSFH9YxkXFoT6/o4bRZDq2tTHrog=="], - - "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], - - "bowser": ["bowser@2.15.0", "", {}, "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "happy-dom": ["happy-dom@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -221,22 +479,40 @@ "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], + + "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], + "rolldown": ["rolldown@1.2.16", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.16", "@rolldown/binding-android-arm64": "1.2.16", "@rolldown/binding-darwin-arm64": "1.2.16", "@rolldown/binding-darwin-x64": "1.2.16", "@rolldown/binding-freebsd-x64": "1.2.16", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", "@rolldown/binding-linux-arm64-gnu": "1.2.16", "@rolldown/binding-linux-arm64-musl": "1.2.16", "@rolldown/binding-linux-ppc64-gnu": "1.2.16", "@rolldown/binding-linux-s390x-gnu": "1.2.16", "@rolldown/binding-linux-x64-gnu": "1.2.16", "@rolldown/binding-linux-x64-musl": "1.2.16", "@rolldown/binding-openharmony-arm64": "1.2.16", "@rolldown/binding-win32-arm64-msvc": "1.2.16", "@rolldown/binding-win32-x64-msvc": "1.2.16" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "seroval": ["seroval@1.6.12", "", {}, "sha512-CqbIVfk/7GL3ITwZI55lcPbpQbjHZ2DD5cUj4iH7E9YsCfHNJV1mx7yNp86Z84w5IpmkZZS/2s5vPOWmVWSdQg=="], + + "seroval-plugins": ["seroval-plugins@1.6.12", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-+reiwp/lYxiyctSrdvX7OQ1YLrcbWYgBiZrhSFL+V43b0/DGRLNVSDyZINbQe+tWplPOI2fw2dPrdv6hcc09kQ=="], + + "solid-js": ["solid-js@1.9.18", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.6.11", "seroval-plugins": "~1.6.8" } }, "sha512-CHGO5YFk6Y9m/1YSxVtKnXfrW7JD3Tmof8HmR4z6TmCPsS8efo8LnzIR8CQh3cUvffqYm6DWyBFfk8dQw1CNQQ=="], + + "sorted-btree": ["sorted-btree@2.1.0", "", {}, "sha512-AtYXy3lL+5jrATpbymC2bM8anN/3maLkmVCd94MzypnKjokfCid/zeS3rvXedv7W6ffSfqKIGdz3UaJPWRBZ0g=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="], - "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "ws": ["ws@8.22.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], - "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "bun-types/undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..b5eeb87 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +# Hoisted, as in moq: the submodule's build helpers (such as its worklet plugin) +# resolve their tools from the root node_modules. +[install] +linker = "hoisted" diff --git a/js/join/package.json b/js/join/package.json index d5ad0d6..a08eb16 100644 --- a/js/join/package.json +++ b/js/join/package.json @@ -13,13 +13,14 @@ "test": "bun test" }, "dependencies": { - "@moq/net": "^0.4.7", - "@moq/publish": "^0.5.4", - "@moq/room": "^0.2.1", - "@moq/signals": "^0.2.6" + "@moq/net": "workspace:*", + "@moq/publish": "workspace:*", + "@moq/room": "workspace:*", + "@moq/signals": "workspace:*" }, "devDependencies": { "@types/bun": "^1.4.4", + "rolldown": "^1.2.15", "typescript": "7.0.2", "vite": "^8.3.5" } diff --git a/js/join/src/index.ts b/js/join/src/index.ts index f45ac4d..be3d6ba 100644 --- a/js/join/src/index.ts +++ b/js/join/src/index.ts @@ -9,8 +9,9 @@ import * as Net from "@moq/net"; import * as Publish from "@moq/publish"; import { Local } from "@moq/room"; -import { Effect } from "@moq/signals"; -import { type Link, parse } from "./link.ts"; +import { Effect, Signal } from "@moq/signals"; +import { format, type Link, parse } from "./link.ts"; +import * as Renew from "./renew.ts"; /** Where the fragment is kept once it leaves the address bar, so a reload rejoins. */ const STORED = "moq-studio-join"; @@ -53,9 +54,19 @@ function read(): Link { /** Publish into the room until the guest leaves. Returns how to leave. */ function join(link: Link): () => void { const signals = new Effect(); - const connection = new Net.Connection({ url: link.url, enabled: true }); + // The token rides SETUP rather than the URL, and each renewal replaces it in band. + const tokens = new Signal([link.jwt]); + const connection = new Net.Connection({ url: link.url, versions: [Renew.VERSION], tokens, enabled: true }); signals.cleanup(() => connection.close()); + const renewal = Renew.follow(connection.origin, Renew.path(link.name, link.generation), (jwt) => { + tokens.set([jwt]); + // So joining again, or a reload, uses the newest token once the link's has expired. + link.jwt = jwt; + stored((storage) => storage.setItem(STORED, format(link))); + }); + signals.cleanup(() => renewal.close()); + const local = new Local({ connection, identity: Net.Path.from(link.name), diff --git a/js/join/src/link.test.ts b/js/join/src/link.test.ts index 2fb01fc..bc21e1f 100644 --- a/js/join/src/link.test.ts +++ b/js/join/src/link.test.ts @@ -1,29 +1,39 @@ import { expect, test } from "bun:test"; -import { parse } from "./link.ts"; +import { format, parse } from "./link.ts"; const fragment = (params: Record) => new URLSearchParams(params).toString(); -test("a link dials the room with its token in the query", () => { - const link = parse(fragment({ url: "https://relay.example:4443/show/guests", name: "alice", jwt: "a.b.c" })); +test("a link names the room and keeps its token out of the URL", () => { + const link = parse( + fragment({ url: "https://relay.example:4443/show/guests", name: "alice", jwt: "a.b.c", generation: "2" }), + ); expect(link.name).toBe("alice"); expect(link.url.origin).toBe("https://relay.example:4443"); expect(link.url.pathname).toBe("/show/guests"); - expect(link.url.searchParams.get("jwt")).toBe("a.b.c"); + expect(link.url.search).toBe(""); + expect(link.jwt).toBe("a.b.c"); + expect(link.generation).toBe(2); + // A renewed token replaces the link's when the page stores it. + expect(parse(format({ ...link, jwt: "d.e.f" }))).toEqual({ ...link, jwt: "d.e.f" }); }); test("a name with spaces survives the fragment's encoding", () => { - const link = parse(fragment({ url: "https://relay.example/room", name: "Alice Smith", jwt: "t" })); + const link = parse(fragment({ url: "https://relay.example/room", name: "Alice Smith", jwt: "t", generation: "0" })); expect(link.name).toBe("Alice Smith"); + expect(parse(format(link)).name).toBe("Alice Smith"); }); test("an incomplete or malformed link is refused", () => { - const valid = { url: "https://relay.example/room", name: "alice", jwt: "t" }; - for (const key of ["url", "name", "jwt"] as const) { + const valid = { url: "https://relay.example/room", name: "alice", jwt: "t", generation: "0" }; + for (const key of ["url", "name", "jwt", "generation"] as const) { const { [key]: _, ...missing } = valid; expect(() => parse(fragment(missing))).toThrow("incomplete"); } expect(() => parse(fragment({ ...valid, url: "not a url" }))).toThrow("invalid relay"); expect(() => parse(fragment({ ...valid, url: "moqt://relay.example/room" }))).toThrow("cannot dial"); + for (const generation of ["-1", "01", "1.5", "x", "99999999999999999999"]) { + expect(() => parse(fragment({ ...valid, generation }))).toThrow("invalid generation"); + } for (const name of ["a/b", "a\\b", " alice"]) { expect(() => parse(fragment({ ...valid, name }))).toThrow("invalid guest"); } diff --git a/js/join/src/link.ts b/js/join/src/link.ts index 455cd40..c2b4ce1 100644 --- a/js/join/src/link.ts +++ b/js/join/src/link.ts @@ -8,10 +8,14 @@ /** A guest's way into the studio's room. */ export interface Link { - /** The relay at the room, with the token in its query, as `@moq/net` dials it. */ + /** The relay at the room. */ url: URL; /** The guest's identity in the room: one path segment. */ name: string; + /** The token the studio signed for the guest: the link's, or a renewed one. */ + jwt: string; + /** How often the host let the guest back in after a removal, which names where their renewals are. */ + generation: number; } /** Parse a join link's fragment, without its `#`. Throws on a link the studio did not make. */ @@ -20,7 +24,9 @@ export function parse(fragment: string): Link { const relay = params.get("url"); const name = params.get("name"); const jwt = params.get("jwt"); - if (!relay || !name || !jwt) throw new Error("this join link is incomplete; ask the host for a new one"); + const generation = params.get("generation"); + if (!relay || !name || !jwt || !generation) + throw new Error("this join link is incomplete; ask the host for a new one"); let url: URL; try { @@ -35,6 +41,15 @@ export function parse(fragment: string): Link { throw new Error(`this join link names an invalid guest: ${name}`); } - url.searchParams.set("jwt", jwt); - return { url, name }; + if (!/^(0|[1-9][0-9]*)$/.test(generation) || !Number.isSafeInteger(Number(generation))) { + throw new Error(`this join link names an invalid generation: ${generation}`); + } + + return { url, name, jwt, generation: Number(generation) }; +} + +/** The fragment {@link parse} reads `link` back from. */ +export function format(link: Link): string { + const { url, name, jwt, generation } = link; + return new URLSearchParams({ url: url.href, name, jwt, generation: generation.toString() }).toString(); } diff --git a/js/join/src/renew.test.ts b/js/join/src/renew.test.ts new file mode 100644 index 0000000..b13be84 --- /dev/null +++ b/js/join/src/renew.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import * as Net from "@moq/net"; +import { Signal } from "@moq/signals"; +import { follow, path } from "./renew.ts"; + +const encode = (text: string) => new TextEncoder().encode(text); + +/** Resolve once `tokens` holds `count` entries. */ +async function received(tokens: Signal, count: number): Promise { + for (;;) { + const now = tokens.peek(); + if (now.length >= count) return now; + await tokens.changed(); + } +} + +test("each token the studio publishes reaches the page, and only for its own name and generation", async () => { + const origin = new Net.Origin.Producer(); + const tokens = new Signal([]); + const renewal = follow(new Signal(origin), path("alice", 0), (token) => + tokens.update((all) => [...all, token]), + ); + + for (const [name, generation] of [ + ["bob", 0], + ["alice", 1], + ] as const) { + const other = origin.createBroadcast(path(name, generation)); + other.broadcast.createTrack("token").writeFrame({ payload: encode(`${name} ${generation}`) }); + other.announce(); + } + + const publication = origin.createBroadcast(path("alice", 0)); + const track = publication.broadcast.createTrack("token"); + track.writeFrame({ payload: encode("first") }); + publication.announce(); + expect(await received(tokens, 1)).toEqual(["first"]); + + track.writeFrame({ payload: encode("second") }); + expect(await received(tokens, 2)).toEqual(["first", "second"]); + + renewal.close(); + origin.close(); +}); + +test("a group superseded before it completes does not end the renewals", async () => { + const origin = new Net.Origin.Producer(); + const tokens = new Signal([]); + const renewal = follow(new Signal(origin), path("alice", 0), (token) => + tokens.update((all) => [...all, token]), + ); + + const publication = origin.createBroadcast(path("alice", 0)); + const track = publication.broadcast.createTrack("token"); + track.writeFrame({ payload: encode("first") }); + publication.announce(); + expect(await received(tokens, 1)).toEqual(["first"]); + // Opened but never written, as when the studio stops partway through one, + // so the page is reading it when the next arrives. + const partial = track.appendGroup(); + for (let turn = 0; turn < 10; turn++) await new Promise((resolve) => setTimeout(resolve)); + track.writeFrame({ payload: encode("next") }); + expect(await received(tokens, 2)).toEqual(["first", "next"]); + track.writeFrame({ payload: encode("last") }); + expect(await received(tokens, 3)).toEqual(["first", "next", "last"]); + + partial.finish(); + renewal.close(); + origin.close(); +}); diff --git a/js/join/src/renew.ts b/js/join/src/renew.ts new file mode 100644 index 0000000..3e1f921 --- /dev/null +++ b/js/join/src/renew.ts @@ -0,0 +1,66 @@ +/** + * Renewal: while a guest is in the room, the studio publishes a fresh token for + * them every half link lifetime, at a path only their own token may read. The + * page presents each one on its live session, so the guest stays past the + * link's expiry without reconnecting. + * + * @module + */ + +import * as Net from "@moq/net"; +import { Effect, type Getter } from "@moq/signals"; + +/** The version that presents a token on a live session, and the only one the page offers. */ +export const VERSION = "moq-lite-07-wip"; + +/** + * Where the studio publishes `name`'s tokens in `generation`: `Room::renewal_path` in + * `rs/moq-studio/src/guests.rs`. + */ +export function path(name: string, generation: number): Net.Path.Valid { + return Net.Path.from(`.renew/${name}/${generation}`); +} + +/** The track of those tokens, one group each: `TOKEN_TRACK` in `rs/moq-studio/src/guests.rs`. */ +const TRACK = "token"; + +/** + * Hand `renewed` each token the studio publishes at `renewals` in `origin`, across + * the studio's restarts, until the returned effect is closed. + */ +export function follow( + origin: Getter, + renewals: Net.Path.Valid, + renewed: (token: string) => void, +): Effect { + const signals = new Effect(); + signals.run((effect) => { + const reader = effect.get(origin); + if (!reader) return; + const request = reader.follow(renewals); + effect.cleanup(() => request.close()); + + effect.run((effect) => { + const broadcast = effect.get(request.active); + if (!broadcast) return; + const track = broadcast.track(TRACK).subscribe(); + effect.cleanup(() => track.close()); + effect.spawn(async () => { + for (;;) { + const group = await effect.race(track.recvGroup()); + if (!group) return; + let token: string | undefined; + try { + token = await effect.race(group.readString()); + } catch (err) { + // A group a newer token superseded before it completed: read that one. + if (err instanceof Net.Error.Stream && err.code === Net.StreamCode.Old) continue; + throw err; + } + if (token) renewed(token); + } + }); + }); + }); + return signals; +} diff --git a/js/join/tsconfig.json b/js/join/tsconfig.json index 461eb10..b8c2827 100644 --- a/js/join/tsconfig.json +++ b/js/join/tsconfig.json @@ -7,7 +7,7 @@ "allowImportingTsExtensions": true, "noEmit": true, "isolatedModules": true, - "types": ["bun"], + "types": ["bun", "vite/client"], "allowUnreachableCode": false, "allowUnusedLabels": false, @@ -21,5 +21,5 @@ "erasableSyntaxOnly": true, "skipLibCheck": true }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "../../moq/js/common/worklet.d.ts"] } diff --git a/js/join/vite.config.ts b/js/join/vite.config.ts index 4654c3e..8a27d5d 100644 --- a/js/join/vite.config.ts +++ b/js/join/vite.config.ts @@ -1,11 +1,18 @@ import { defineConfig } from "vite"; +import { worklet } from "../../moq/js/common/vite-plugin-worklet.ts"; export default defineConfig({ // Relative asset URLs, so the built page works from any path it is hosted under. base: "./", + // The moq packages are sources, whose audio worklets and video worker this bundles. + plugins: [worklet()], build: { target: "esnext", // @moq/publish's capture and encode pipeline is most of the page, in one chunk. chunkSizeWarningLimit: 1024, }, + optimizeDeps: { + // A prebuilt wasm module, which the dev server's dependency scan cannot bundle. + exclude: ["@moq/libav"], + }, }); diff --git a/justfile b/justfile index 2a053c9..3697418 100644 --- a/justfile +++ b/justfile @@ -60,7 +60,7 @@ render-sw: xvfb=$(nix build --inputs-from . --no-link --print-out-paths nixpkgs#xvfb-run) "$xvfb/bin/xvfb-run" -a cargo nextest run --locked -p moq-studio --features cef --run-ignored only -E 'binary(browser)' -# Move the moq submodule to moq's latest main (or a given commit) and refresh Cargo.lock. +# Move the moq submodule to moq's latest main (or a given commit) and refresh Cargo.lock and bun.lock. upgrade-moq rev="origin/main": sh/upgrade-moq.sh {{ rev }} diff --git a/package.json b/package.json index f2ae849..507a467 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,29 @@ "version": "0.0.0", "private": true, "type": "module", + "//workspaces": "The join page builds on the moq submodule's packages, like the Rust crates, so it never waits on a moq release. Every @moq package it reaches is listed, since a workspace resolves only workspace packages.", "workspaces": [ - "js/join" + "js/join", + "moq/js/clock", + "moq/js/hang", + "moq/js/json", + "moq/js/libav", + "moq/js/loc", + "moq/js/msf", + "moq/js/net", + "moq/js/pattern", + "moq/js/publish", + "moq/js/qmux", + "moq/js/room", + "moq/js/signals", + "moq/js/ui", + "moq/js/video", + "moq/js/watch" ], "scripts": { - "check": "bun run --filter='*' check", - "test": "bun run --filter='*' test", + "postinstall": "bun run --cwd moq/js/libav build", + "check": "bun run --filter='./js/*' check", + "test": "bun run --filter='./js/*' test", "fix": "bun biome check --write ." }, "devDependencies": { diff --git a/quest/m0/README.md b/quest/m0/README.md index 8049c11..5182a66 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -102,7 +102,6 @@ repository. - [Multiple canvases](/quest/m0/multi-canvas.md) - landscape and portrait canvases composite at once from shared sources - [The join page is live](/quest/m0/join-page-live.md) - `join.studio.moq.dev` serves the join page `--join-page` defaults to - [Guest return feed](/quest/m0/guest-return.md) - guests see the program and hear a mix-minus -- [Guest token refresh](/quest/m0/guest-refresh.md) - a show outlasts the guest link's expiry without new links - [Audio mixes](/quest/m0/mixes.md) - per-output mixes, meters, monitoring, and sync offsets - [Audio filters](/quest/m0/audio-filters.md) - per-source noise suppression, gate, compressor, limiter, and plugins, configured from the studio - [Recording](/quest/m0/recording.md) - each canvas's program, and optionally each source, recorded to disk alongside live outputs diff --git a/quest/m0/guest-refresh.md b/quest/m0/guest-refresh.md deleted file mode 100644 index 3f730f3..0000000 --- a/quest/m0/guest-refresh.md +++ /dev/null @@ -1,19 +0,0 @@ -# [M] Guest token refresh - -## Goal - -A show can outlast `--guest-expiry`: a joined guest's credential is renewed -while they stay in the room, without the host sending a new link and without -the guest reconnecting. - -## Plan - -A signed guest token cannot be revoked, and the relay ends a session at its -`exp`, which is why links expire today. The studio holds the room key, so it -signs each renewed token, and the token has to reach the connected guest -before `exp` over something the room already provides, such as a track only -that guest may subscribe to; the join page then re-authenticates with it. -Renew over the transport's own re-authentication if moq supports it, or land -that in moq first; do not invent a guest protocol. A host's Remove from scenes -should stop renewal, so the expiry bounds a removed guest again. Raised on -[#10](https://github.com/moq-dev/studio/pull/10). diff --git a/quest/m0/guest-return.md b/quest/m0/guest-return.md index 78e8625..74088b2 100644 --- a/quest/m0/guest-return.md +++ b/quest/m0/guest-return.md @@ -12,7 +12,9 @@ The join page plays it. Decided 2026-11-02 (maintainer): guests get a mix-minus, not the full program, so this waits on audio mixes; video and audio land together. Publish into the room the studio already owns, under names the room's token scope allows, and -keep it free of the studio's own credentials. One mix-minus per guest grows +keep it free of the studio's own credentials. A guest's token subscribes only +to its own renewals (`.renew//`), so it must also grant the feed's +paths, still leaving other guests' renewals out. One mix-minus per guest grows with the guest count, so bound it by design (the guest roster is already bounded to guests the studio signed for). Raised on [#10](https://github.com/moq-dev/studio/pull/10). diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 7e773b1..b7f4918 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -63,13 +63,18 @@ moq-video = { workspace = true } pollster = { workspace = true } # The join link's QR code. No dependencies of its own. qrcodegen = "1.8" +# The guests removed from scenes, kept beside the scene file. +serde = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } +toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } usage-rs = { workspace = true, features = ["completions", "config"] } [dev-dependencies] +# A clock that moves wall time too, which renewed tokens' expiries read. +kio = { workspace = true, features = ["manual"] } # Reads a saved clip's timeline back; the version moq-mux writes it with. mp4-atom = "0.17.0" # The auth server a test relay verifies guest tokens with. diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs index c96809c..6aba75a 100644 --- a/rs/moq-studio/src/guests.rs +++ b/rs/moq-studio/src/guests.rs @@ -9,10 +9,18 @@ //! each as a muted [`Source::Broadcast`] when it is announced. Only the room key //! mints those tokens, so guests can make the studio hold at most two sources //! per name it signed for. - -use std::collections::BTreeMap; +//! +//! A token cannot be revoked, and the relay ends a session at its expiry. So +//! while a guest is in the room, the studio signs them a fresh token every half +//! `--guest-expiry` and publishes it at `.renew//`, +//! which only their token may subscribe to. The join page presents each one in +//! band. Removing a guest from scenes stops their renewals, across restarts, +//! until a new link, which moves them to the next generation, so their last +//! token bounds how long they stay. + +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use anyhow::Context; @@ -26,6 +34,18 @@ use crate::engine::Engine; /// The domain `.github/workflows/join.yml` deploys `js/join` to. const JOIN_PAGE: &str = "https://join.studio.moq.dev/"; +/// The prefix of each guest's renewed tokens, which moq-room's announcements +/// hide; [`Room::renewal_path`] names the rest. `js/join` reads the same path. +const RENEW: &str = ".renew"; + +/// The track holding a guest's renewed tokens, one group each. `js/join` reads +/// the same name. +const TOKEN_TRACK: &str = "token"; + +/// The shortest `--guest-expiry`: half of it must leave room for a renewal to +/// reach the guest and the relay before the token it replaces expires. +const MIN_EXPIRY: Duration = Duration::from_secs(60); + /// The guest flags. #[derive(usage_rs::Args, Clone, Debug)] #[usage(unknown_flags = "error", args_override_self = false)] @@ -48,15 +68,16 @@ pub struct Config { #[usage(long)] pub join_page: Option, - /// How long a join link works. The relay disconnects a guest when its link - /// expires, so send a new one before then for a longer show. + /// How long a join link, and each token the studio renews it with, works. A + /// guest in the room is renewed every half of this, so a guest removed from + /// scenes is disconnected within it. At least a minute. #[usage(long, default = "4h")] pub guest_expiry: moq_tokio::cli::Duration, } impl Config { /// The room these flags describe on the relay at `connect`, or `None` without - /// `--room`. + /// `--room`, remembering who was removed from scenes beside the room key. pub fn load(&self, connect: Option<&Url>) -> anyhow::Result> { let (room, key) = match (&self.room, &self.room_key, &self.join_page) { (None, None, None) => return Ok(None), @@ -68,12 +89,34 @@ impl Config { .clone() .unwrap_or_else(|| Url::parse(JOIN_PAGE).expect("the default join page is a URL")); let connect = connect.context("guests join through the relay; add --connect")?; + let removals = removals_path(key); let key = moq_auth::Key::from_file(key).with_context(|| format!("reading the room key {}", key.display()))?; let room = Room::new(connect, room, key, page, self.guest_expiry.into())?; - Ok(Some(room)) + Ok(Some(room.remember(removals)?)) } } +/// Where the guests removed from scenes, and how often each was let back in, +/// are kept: `room.guests.toml` for `room.jwk`. Beside the key, since the +/// tokens they bound are the key's, whichever scene file the studio opens. +fn removals_path(key: &std::path::Path) -> PathBuf { + let mut name = key.file_stem().unwrap_or_default().to_os_string(); + name.push(".guests.toml"); + key.with_file_name(name) +} + +/// The file at [`removals_path`]. +#[derive(Default, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Removals { + /// The guests whose tokens are not renewed until the host makes them a new link. + #[serde(default)] + removed: BTreeSet, + /// Each guest's [`Participant::generation`], when not zero. + #[serde(default)] + generation: BTreeMap, +} + /// The studio's room: who was invited, and what each guest publishes. pub struct Room { /// The room, relative to the studio's connection. @@ -86,6 +129,12 @@ pub struct Room { page: Url, expiry: Duration, participants: Mutex>, + /// Where removals are kept, or `None` to keep them in memory alone. + removals: Option, + /// Wakes the renewals when who is renewed changes. + changed: tokio::sync::Notify, + /// The session publishing the renewals, once it dials. + session: OnceLock, } /// One guest the studio knows of. @@ -97,6 +146,21 @@ pub struct Participant { pub camera: bool, /// Whether the guest's screen broadcast is announced. pub screen: bool, + /// Whether the host removed them from scenes since their newest link, which + /// stops renewing their token, across restarts. + pub removed: bool, + /// How often a new link let them back in after a removal. Their renewals are + /// published under it, so a token from before cannot read them. + pub generation: u64, +} + +impl Participant { + /// Whether the studio renews their token: they are in the room, and the host + /// has not removed them. + pub fn renewed(&self) -> bool { + // The join page announces the camera broadcast for as long as it is joined. + self.camera && !self.removed + } } /// A join link for one guest. @@ -131,8 +195,10 @@ impl Room { "browsers open the camera only on a secure page, so the join page {page} must be https://, \ or http:// on localhost" ); - // The token carries whole seconds, so anything shorter expires as it is made. - anyhow::ensure!(expiry.as_secs() > 0, "--guest-expiry must be at least a second"); + anyhow::ensure!( + expiry >= MIN_EXPIRY, + "--guest-expiry must be at least a minute, so a renewal reaches each guest before their token expires" + ); let path = Path::new(room).with_context(|| format!("invalid room `{room}`"))?; anyhow::ensure!(!path.is_empty(), "the room needs a path"); let base = Path::new(connect.path().trim_matches('/')).context("invalid --connect path")?; @@ -161,12 +227,79 @@ impl Room { page, expiry, participants: Mutex::default(), + removals: None, + changed: Default::default(), + session: Default::default(), }; - // Sign once now, so a key that cannot sign for guests fails at startup. - room.sign("guest")?; + // Sign once now, so a key that cannot sign for guests or for the studio's + // own session fails at startup. + room.sign("guest", 0)?; + room.sign_session()?; Ok(room) } + /// Keep removals in the file at `path`, starting from those it holds, so a + /// restart does not renew a guest the host removed. + fn remember(mut self, path: PathBuf) -> anyhow::Result { + let removals: Removals = match std::fs::read_to_string(&path) { + Ok(text) => toml::from_str(&text).with_context(|| format!("reading {}", path.display()))?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Removals::default(), + Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())), + }; + let participants = self.participants.get_mut().unwrap(); + for identity in removals.removed { + participants.entry(identity).or_default().removed = true; + } + for (identity, generation) in removals.generation { + participants.entry(identity).or_default().generation = generation; + } + self.removals = Some(path); + Ok(self) + } + + /// Apply `change` to `identity` once the file holds it, so a failed write + /// leaves both as they were. + fn change( + &self, + participants: &mut BTreeMap, + identity: &str, + change: impl FnOnce(&mut Participant), + ) -> anyhow::Result<()> { + let participant = participants.entry(identity.to_string()).or_default(); + let before = participant.clone(); + change(participant); + let saved = self.save_removals(participants); + if saved.is_err() { + participants.insert(identity.to_string(), before); + } + saved + } + + /// Write the removals to their file, if they are kept in one. + fn save_removals(&self, participants: &BTreeMap) -> anyhow::Result<()> { + let Some(path) = &self.removals else { return Ok(()) }; + let removed = participants.iter().filter(|(_, participant)| participant.removed); + let generation = participants + .iter() + .filter(|(_, participant)| participant.generation > 0); + let removals = Removals { + removed: removed.map(|(identity, _)| identity.clone()).collect(), + generation: generation + .map(|(identity, participant)| (identity.clone(), participant.generation)) + .collect(), + }; + let context = || format!("writing {}", path.display()); + let dir = match path.parent() { + Some(dir) if !dir.as_os_str().is_empty() => dir, + _ => std::path::Path::new("."), + }; + // Replaced whole, so a crash never leaves half a file. + let mut file = tempfile::NamedTempFile::new_in(dir).with_context(context)?; + std::io::Write::write_all(&mut file, toml::to_string(&removals)?.as_bytes()).with_context(context)?; + file.persist(path).map_err(|err| err.error).with_context(context)?; + Ok(()) + } + /// The room, relative to the studio's connection. pub fn path(&self) -> &Path { &self.path @@ -189,41 +322,106 @@ impl Room { // The room's announcements hide a path segment starting with `.`, so the // studio would never see this guest join. anyhow::ensure!(!name.starts_with('.'), "a guest's name cannot start with `.`"); - let (token, expires) = self.sign(name)?; + let mut participants = self.participants.lock().unwrap(); + let removed = participants.get(name).is_some_and(|participant| participant.removed); + let generation = participants.get(name).map_or(0, |participant| participant.generation); + // Refused before anything changes, as a name that cannot be a guest. + let (token, expires) = self.sign(name, generation + u64::from(removed))?; + // A new link lets a removed guest back in, under a new generation, so a + // token from before the removal cannot read the renewals. + if removed { + self.change(&mut participants, name, |participant| { + participant.removed = false; + participant.generation += 1; + })?; + } let mut link = self.page.clone(); let fragment = url::form_urlencoded::Serializer::new(String::new()) .append_pair("url", self.url.as_str()) .append_pair("name", name) .append_pair("jwt", &token) + .append_pair("generation", &(generation + u64::from(removed)).to_string()) .finish(); link.set_fragment(Some(&fragment)); let invite = Invite { link, expires }; - self.participants - .lock() - .unwrap() - .entry(name.to_string()) - .or_default() - .invite = Some(invite.clone()); + participants.entry(name.to_string()).or_default().invite = Some(invite.clone()); + drop(participants); + self.changed.notify_one(); Ok(invite) } - /// A token for `identity`, and when it expires. - fn sign(&self, identity: &str) -> anyhow::Result<(String, kio::clock::Wall)> { - // Whole seconds, as the token carries them. + /// Stop renewing the token of the guest called `identity`, across restarts, + /// until the host makes them a new link. + pub fn stop_renewing(&self, identity: &str) -> anyhow::Result<()> { + let mut participants = self.participants.lock().unwrap(); + if !participants + .get(identity) + .is_some_and(|participant| participant.removed) + { + self.change(&mut participants, identity, |participant| participant.removed = true)?; + } + drop(participants); + self.changed.notify_one(); + Ok(()) + } + + /// Where `identity`'s renewals in `generation` are published, in the room. + fn renewal_path(identity: &str, generation: u64) -> anyhow::Result { + Ok(Path::new(&format!("{RENEW}/{identity}/{generation}"))?) + } + + /// Why the session publishing guests' renewed tokens is not connected, if it + /// is not. + pub fn renewal_failure(&self) -> Option { + let failure = self.session.get()?.state().failure?; + Some(format!("{:#}", anyhow::Error::from(failure))) + } + + /// A token for `identity`, and when it expires. It publishes moq-room's + /// paths but subscribes only to its own renewals, so no guest reads + /// another's. + fn sign(&self, identity: &str, generation: u64) -> anyhow::Result<(String, kio::clock::Wall)> { + let invalid = || format!("cannot invite `{identity}`"); + let renewals = Self::renewal_path(identity, generation).with_context(invalid)?; + let claims = moq_room::claims(self.root.as_str(), identity) + .with_context(invalid)? + .with_subscribe([moq_auth::Pattern::literal(renewals.as_str())?]); + self.sign_claims(claims) + .context("the room key cannot sign a guest's token") + } + + /// A token for the studio's own session in the room, which publishes the + /// renewals and nothing else. + fn sign_session(&self) -> anyhow::Result<(String, kio::clock::Wall)> { + let claims = moq_auth::Claims::default() + .with_root(self.root.as_str()) + .with_publish([moq_auth::Pattern::subtree(RENEW)?]); + self.sign_claims(claims) + .context("the room key cannot sign the studio's own session in the room") + } + + /// Sign `claims` to expire no sooner than one `--guest-expiry` from now. + fn sign_claims(&self, claims: moq_auth::Claims) -> anyhow::Result<(String, kio::clock::Wall)> { + // Whole seconds, as the token carries them: issued rounded down and + // expiring rounded up, so a renewal every half expiry always comes first. let now = kio::clock::wall().saturating_duration_since(kio::clock::Wall::UNIX_EPOCH); let issued = kio::clock::Wall::UNIX_EPOCH + Duration::from_secs(now.as_secs()); - let expires = issued + self.expiry; - let claims = moq_room::claims(self.root.as_str(), identity) - .with_context(|| format!("cannot invite `{identity}`"))? - .with_issued(issued) - .with_expires(expires); - let token = self - .key - .sign(&claims) - .context("the room key cannot sign a guest's token")?; + let end = now + self.expiry; + let expires = + kio::clock::Wall::UNIX_EPOCH + Duration::from_secs(end.as_secs() + u64::from(end.subsec_nanos() > 0)); + let token = self.key.sign(&claims.with_issued(issued).with_expires(expires))?; Ok((token, expires)) } + /// The guests whose tokens are renewed, and in which generation. + fn renewed(&self) -> Vec<(String, u64)> { + let participants = self.participants.lock().unwrap(); + let renewed = participants.iter().filter(|(_, participant)| participant.renewed()); + renewed + .map(|(identity, participant)| (identity.clone(), participant.generation)) + .collect() + } + /// Record that the broadcast at `path` in the room went online or offline, /// returning the source to add for it, if the collection lacks one. Its /// name is one `history` never edited, so the source can be added outside it. @@ -258,6 +456,8 @@ impl Room { Kind::Camera => participant.camera = online, Kind::Screen => participant.screen = online, } + drop(participants); + self.changed.notify_one(); if !online || existing { return None; } @@ -417,6 +617,157 @@ pub async fn run(engine: Arc, origin: moq_net::origin::Consumer) { tracing::warn!("the room's announcements ended"); } +/// The studio's own session in the room, which publishes each renewed guest's +/// tokens, and renews its own. +pub struct Renewal { + renewals: Renewals, + connection: moq_tokio::Connection, +} + +impl Renewal { + /// Dial `room`'s relay with `client`'s settings but none of its credentials, + /// as a session the room key signs for. It offers only moq-lite-07, the + /// version that renews a token without reconnecting. + pub fn connect( + room: Arc, + client: &moq_tokio::connect::Config, + quic: moq_tokio::quic::Config, + ) -> anyhow::Result { + let mut client = client.clone(); + // The main connection's socket, if `--connect-bind` fixed its port. + anyhow::ensure!( + client.bind.is_none_or(|bind| bind.port() == 0), + "guests cannot share a fixed --connect-bind port with the studio's session in the room; leave the port 0" + ); + // The studio's own credentials stay on its own connection: this session + // holds only what the room key signs, as a guest does. + client.url = None; + client.tls.cert = None; + client.tls.key = None; + client.tls.identity = None; + let (token, _) = room.sign_session()?; + client.tokens = vec![token.try_into()?]; + client.version = vec![LITE_07.parse().map_err(anyhow::Error::msg)?]; + let client = client + .init(quic) + .context("cannot set up the studio's session in the room")?; + let origin = moq_tokio::origin::spawn(); + let connection = client.with_publisher(&origin).connect(room.url.clone()); + let _ = room.session.set(connection.monitor()); + let renewals = Renewals::new(room, origin); + Ok(Self { renewals, connection }) + } + + /// Publish a token to each guest as they join, and renew every token, the + /// session's own included, every half `--guest-expiry`, for as long as the + /// studio runs. + pub async fn run(mut self) { + let room = self.renewals.room.clone(); + let period = room.expiry / 2; + let mut renew = kio::clock::interval_at(kio::clock::now() + period, period); + loop { + self.renewals.sync(); + tokio::select! { + _ = renew.tick() => { + match room.sign_session().and_then(|(token, _)| Ok(token.try_into()?)) { + Ok(token) => self.connection.tokens().set(vec![token]), + Err(err) => tracing::error!(error = format!("{err:#}"), "cannot renew the studio's session in the room"), + } + self.renewals.renew(); + } + () = room.changed.notified() => {} + } + } + } +} + +/// The moq-lite version that carries a token on a live session. +const LITE_07: &str = "moq-lite-07-wip"; + +/// Each renewed guest's token track, in the origin the studio's session in the +/// room publishes. +struct Renewals { + room: Arc, + origin: moq_net::origin::Producer, + tracks: BTreeMap, +} + +/// One guest's token track, ended when dropped. +struct Renewed { + generation: u64, + publication: moq_net::origin::Publication, + track: moq_net::track::Producer, +} + +impl Drop for Renewed { + fn drop(&mut self) { + let _ = self.track.finish(); + self.publication.close(); + } +} + +impl Renewals { + fn new(room: Arc, origin: moq_net::origin::Producer) -> Self { + Self { + room, + origin, + tracks: BTreeMap::new(), + } + } + + /// Publish a token to each guest who is newly renewed, and end the tracks + /// of those who are not anymore. + fn sync(&mut self) { + let renewed = self.room.renewed(); + self.tracks + .retain(|identity, track| renewed.contains(&(identity.clone(), track.generation))); + for (identity, generation) in renewed { + if self.tracks.contains_key(&identity) { + continue; + } + match self.publish(&identity, generation) { + Ok(track) => { + self.tracks.insert(identity, track); + } + Err(err) => tracing::error!(%identity, error = format!("{err:#}"), "cannot renew a guest's token"), + } + } + } + + /// The token track for `identity` in `generation`, holding a fresh token. + fn publish(&self, identity: &str, generation: u64) -> anyhow::Result { + let publication = self + .origin + .create_broadcast(Room::renewal_path(identity, generation)?)?; + let track = publication.create_track(TOKEN_TRACK, None)?; + let mut renewed = Renewed { + generation, + publication, + track, + }; + self.write(identity, &mut renewed)?; + renewed.publication.announce(Default::default())?; + Ok(renewed) + } + + /// A fresh token for every renewed guest. + fn renew(&mut self) { + let mut tracks = std::mem::take(&mut self.tracks); + for (identity, renewed) in &mut tracks { + if let Err(err) = self.write(identity, renewed) { + tracing::error!(%identity, error = format!("{err:#}"), "cannot renew a guest's token"); + } + } + self.tracks = tracks; + } + + fn write(&self, identity: &str, renewed: &mut Renewed) -> anyhow::Result<()> { + let (token, _) = self.room.sign(identity, renewed.generation)?; + renewed.track.write_frame(None, token)?; + Ok(()) + } +} + /// Add a guest's broadcast as the source `name`, muted: it is in no scene, so /// the guest reaches the program only once the host places or unmutes them. fn admit(draft: &mut Draft, name: &str, source: Source) -> Result<(), moq_scene::Error> { @@ -583,10 +934,14 @@ mod tests { } #[test] - fn refuses_a_link_that_expires_as_it_is_made() { + fn refuses_an_expiry_too_short_to_renew() { let connect = Url::parse("https://relay.example/show").unwrap(); let page = Url::parse("https://studio.example/join/").unwrap(); - for expiry in [Duration::ZERO, Duration::from_millis(500)] { + for expiry in [ + Duration::ZERO, + Duration::from_millis(500), + MIN_EXPIRY - Duration::from_millis(1), + ] { let key = key("show/guests", &["**"], &["**"]); let err = Room::new(&connect, "guests", key, page.clone(), expiry).err().unwrap(); assert!(err.to_string().contains("--guest-expiry"), "{expiry:?}: {err}"); @@ -612,13 +967,22 @@ mod tests { let fragment = fragment(&invite.link); assert_eq!(fragment["url"], "https://relay.example:4443/show/guests"); assert_eq!(fragment["name"], "alice"); + assert_eq!(fragment["generation"], "0"); let claims = key.verify(&fragment["jwt"]).unwrap(); assert_eq!(claims.root, "show/guests"); assert_eq!(claims.publish, ["alice/**".parse().unwrap()].into_iter().collect()); - assert_eq!(claims.subscribe, [moq_auth::Pattern::all()].into_iter().collect()); + assert_eq!( + claims.subscribe, + [".renew/alice/0".parse().unwrap()].into_iter().collect(), + "a guest reads only its own renewals" + ); let (issued, expires) = (claims.issued.unwrap(), claims.expires.unwrap()); - assert_eq!(expires.saturating_duration_since(issued), EXPIRY); + let lifetime = expires.saturating_duration_since(issued); + assert!( + lifetime == EXPIRY || lifetime == EXPIRY + Duration::from_secs(1), + "{lifetime:?}" + ); assert_eq!(invite.expires, expires); assert!(room.participants()["alice"].invite.is_some()); } @@ -668,6 +1032,8 @@ mod tests { invite: None, camera: true, screen: false, + removed: false, + generation: 0, } ); @@ -698,9 +1064,11 @@ mod tests { } /// A guest who opens the link reaches the relay with its token, and the - /// studio's own connection sees its camera in the room. + /// studio's own connection sees its camera in the room. The studio's session + /// in the room then hands the guest a fresh token, which the relay accepts in + /// band, and which no other guest may read. #[tokio::test(flavor = "multi_thread")] - async fn a_guest_joins_through_a_relay_that_verifies_tokens() { + async fn a_guest_joins_and_renews_through_a_relay_that_verifies_tokens() { const TIMEOUT: Duration = Duration::from_secs(10); let _ = moq_tokio::crypto::install_default(); @@ -727,16 +1095,23 @@ mod tests { config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.auth.url = Some(auth); + // Every version, as a relay serving the studio and its join page opts into + // the one that renews tokens in band. + config.listen.version = moq_net::Versions::all() + .iter() + .copied() + .chain([LITE_07.parse().unwrap()]) + .collect(); let relay = moq_relay::Relay::load(config).await.unwrap().with_signals(false); let addr = relay.quic_addr().unwrap(); let ready = relay.ready(); tokio::spawn(relay.run()); ready.wait().await.unwrap(); - let mut client = moq_tokio::connect::Config::default(); + let mut settings = moq_tokio::connect::Config::default(); // The relay's certificate is generated for this test alone. - client.tls.insecure = Some(true); - let client = client.init(Default::default()).unwrap(); + settings.tls.insecure = Some(true); + let client = settings.clone().init(Default::default()).unwrap(); let studio = moq_auth::Claims::default() .with_root("show") @@ -748,17 +1123,36 @@ mod tests { let subscribe = moq_tokio::origin::spawn(); let all = moq_auth::Pattern::all().into(); let mut announced = moq_room::Room::new(&subscribe.consume().scope("guests", &all).unwrap(), None); - let _studio = client.clone().with_subscriber(subscribe).connect(connect.clone()); + let _studio = client.with_subscriber(subscribe).connect(connect.clone()); let page = Url::parse("https://studio.example/join/").unwrap(); - let room = Room::new(&connect, "guests", scoped, page, EXPIRY).unwrap(); + let room = Arc::new(Room::new(&connect, "guests", scoped.clone(), page, EXPIRY).unwrap()); + let renewal = Renewal::connect(room.clone(), &settings, Default::default()).unwrap(); + tokio::spawn(renewal.run()); + // Another guest in the room, whose renewals alice must not read. + room.observe( + &Path::new("bob/camera.hang").unwrap(), + true, + &Collection::new(), + &History::new(), + ); + + // As the join page dials: its token kept out of the URL, on the version + // that renews it in band. let fragment = fragment(&room.invite("alice").unwrap().link); - let mut dial = Url::parse(&fragment["url"]).unwrap(); - dial.query_pairs_mut().append_pair("jwt", &fragment["jwt"]); + let mut page = settings.clone(); + page.tokens = vec![fragment["jwt"].clone().try_into().unwrap()]; + page.version = vec![LITE_07.parse().unwrap()]; let guest = moq_tokio::origin::spawn(); let camera = guest.create_broadcast("alice/camera.hang").unwrap(); camera.announce(moq_net::origin::Route::default()).unwrap(); - let _guest = client.with_publisher(&guest).connect(dial); + let renewals = moq_tokio::origin::spawn(); + let alice = page + .init(Default::default()) + .unwrap() + .with_publisher(&guest) + .with_subscriber(renewals.clone()) + .connect(Url::parse(&fragment["url"]).unwrap()); let event = kio::clock::timeout(TIMEOUT, announced.next()) .await @@ -776,6 +1170,153 @@ mod tests { path: "guests/alice/camera.hang".into() } ); + + let token = kio::clock::timeout(TIMEOUT, next_token(&renewals.consume(), "alice", 0)) + .await + .expect("the studio renews the guest") + .expect("a token"); + alice.tokens().set(vec![token.try_into().unwrap()]); + kio::clock::timeout(TIMEOUT, alice.tokens().answered()) + .await + .expect("the relay answers the renewal") + .expect("the relay accepts the renewed token in band"); + + // Published before alice's, over the same session, so the relay holds it. + let other = kio::clock::timeout(TIMEOUT, async { + let broadcast = renewals.consume().request(Room::renewal_path("bob", 0)?, None).await?; + read_token(broadcast).await + }) + .await + .expect("the relay answers"); + assert!(other.is_err(), "alice read bob's token"); + } + + /// The newest token published for `identity` in `origin`, once there is one. + async fn next_token(origin: &moq_net::origin::Consumer, identity: &str, generation: u64) -> anyhow::Result { + let broadcast = origin + .follow(Room::renewal_path(identity, generation)?, None)? + .available() + .await?; + read_token(broadcast).await + } + + async fn read_token(broadcast: moq_net::broadcast::Consumer) -> anyhow::Result { + let mut track = broadcast.track(TOKEN_TRACK)?.subscribe(None).await?; + let mut group = track.recv_group().await?.context("the track ended")?; + let frame = group.read_frame().await?.context("the group is empty")?; + Ok(String::from_utf8(frame.payload.to_vec())?) + } + + /// The tokens a renewed guest holds, as they read them: fresh while they are + /// in the room, each at least `--guest-expiry` long, and none once the host + /// removes them, until a new link. + #[tokio::test] + async fn a_guest_in_the_room_is_renewed_until_removed() { + let clock = kio::clock::Manual::new().install(&tokio::runtime::Handle::current()); + // Partway through a second, which the tokens round to. + clock.advance(Duration::from_millis(750)); + let key = key("show/guests", &["**"], &["**"]); + let room = Arc::new(room("https://relay.example/show", key.clone()).unwrap()); + let origin = moq_tokio::origin::spawn(); + let mut renewals = Renewals::new(room.clone(), origin.clone()); + let consumer = origin.consume(); + let path = |text: &str| Path::new(text).unwrap(); + let join = |online: bool| room.observe(&path("alice/camera.hang"), online, &Collection::new(), &History::new()); + + // Invited, or sharing a screen without the page joined, is not in the room. + room.invite("alice").unwrap(); + room.observe(&path("alice/screen.hang"), true, &Collection::new(), &History::new()); + renewals.sync(); + assert!(renewals.tracks.is_empty()); + + join(true); + renewals.sync(); + let first = key.verify(&next_token(&consumer, "alice", 0).await.unwrap()).unwrap(); + assert_eq!(first.root, "show/guests"); + assert_eq!(first.publish, ["alice/**".parse().unwrap()].into_iter().collect()); + // Each renewal comes half an expiry later, so a token must outlast that. + let ahead = |claims: &moq_auth::Claims| claims.expires.unwrap().saturating_duration_since(kio::clock::wall()); + assert!(ahead(&first) >= EXPIRY, "{:?}", ahead(&first)); + + clock.advance(EXPIRY / 2); + assert!(ahead(&first) >= EXPIRY / 2, "renewed before the token expires"); + renewals.renew(); + let second = key.verify(&next_token(&consumer, "alice", 0).await.unwrap()).unwrap(); + assert!(ahead(&second) >= EXPIRY, "{:?}", ahead(&second)); + + room.stop_renewing("alice").unwrap(); + assert!(room.participants()["alice"].removed); + renewals.sync(); + assert!(renewals.tracks.is_empty(), "a removed guest's renewals end"); + + // Let back in under a new generation, which a token from before cannot read. + room.invite("alice").unwrap(); + renewals.sync(); + let back = key.verify(&next_token(&consumer, "alice", 1).await.unwrap()).unwrap(); + assert_eq!( + back.subscribe, + [".renew/alice/1".parse().unwrap()].into_iter().collect() + ); + let before = consumer.request(Room::renewal_path("alice", 0).unwrap(), None).await; + assert!(before.is_err(), "the earlier generation is still renewed"); + + join(false); + renewals.sync(); + assert!(renewals.tracks.is_empty(), "a guest who left is not renewed"); + } + + /// A removed guest stays unrenewed when the studio restarts, whichever + /// scene file it opens, until a new link, whose generation a restart keeps + /// too: the state is the room key's, kept beside it. + #[test] + fn a_removal_outlasts_a_restart() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("keys")).unwrap(); + let path = dir.path().join("keys/room.jwk"); + key("show/guests", &["**"], &["**"]).to_file(&path).unwrap(); + let connect = Url::parse("https://relay.example/show").unwrap(); + let config = Config { + room: Some("guests".into()), + room_key: Some(path), + join_page: None, + guest_expiry: EXPIRY.into(), + }; + // Nothing the studio loads names the scene file, so another one sees the same state. + let start = || config.load(Some(&connect)).unwrap().unwrap(); + let camera = Path::new("alice/camera.hang").unwrap(); + + let room = start(); + room.invite("alice").unwrap(); + room.stop_renewing("alice").unwrap(); + let file = dir.path().join("keys/room.guests.toml"); + assert!(file.exists()); + + let room = start(); + room.observe(&camera, true, &Collection::new(), &History::new()); + assert!(room.participants()["alice"].removed); + assert!(room.renewed().is_empty(), "a removed guest is renewed after a restart"); + room.invite("alice").unwrap(); + let alice = vec![("alice".to_string(), 1)]; + assert_eq!(room.renewed(), alice); + + let room = start(); + room.observe(&camera, true, &Collection::new(), &History::new()); + assert_eq!(room.renewed(), alice, "a new link is remembered too"); + room.invite("alice").unwrap(); + assert_eq!( + room.renewed(), + alice, + "only letting a removed guest back in moves them on" + ); + + // A removal the file cannot hold is not made, so trying again saves it. + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + assert!(room.stop_renewing("alice").is_err()); + assert_eq!(room.renewed(), alice); + std::fs::remove_dir(&file).unwrap(); + room.stop_renewing("alice").unwrap(); + assert!(start().participants()["alice"].removed); } /// A guest joining is not the host's edit: what the host undid can still diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 9e3defd..d7500e8 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -139,13 +139,17 @@ fn main() -> anyhow::Result { let _runtime = runtime.enter(); let relays = config.relays()?; let gpu = pollster::block_on(engine::Gpu::new())?; - let room = config.guests.load(config.client.url.as_ref())?; + let room = config.guests.load(config.client.url.as_ref())?.map(Arc::new); + if let Some(room) = &room { + let renewal = guests::Renewal::connect(room.clone(), &config.client, config.quic.clone())?; + runtime.spawn(renewal.run()); + } let launch = engine::Launch { gpu: gpu.clone(), relays, broadcast: config.broadcast.clone(), on_demand: config.on_demand, - room: room.map(Arc::new), + room, }; let exists = config .scenes diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 7fb1d07..a10b375 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -1400,6 +1400,12 @@ impl Panels { } ui.separator(); + if let Some(failure) = room.renewal_failure() { + ui.colored_label( + ui.visuals().error_fg_color, + format!("Guests' tokens are not renewing, so each leaves when their link expires: {failure}"), + ); + } let participants = room.participants(); if participants.is_empty() { ui.weak("Nobody invited yet."); @@ -1423,12 +1429,17 @@ impl Panels { ui.weak("offline"); } } + // A renewed guest no longer depends on their link's expiry. match &participant.invite { + _ if participant.renewed() => ui.label("renewing"), + _ if participant.removed => ui.weak("not renewed"), Some(link) => ui.label(expiry(link.expires, now)), None => ui.weak("invited before this session"), }; ui.horizontal(|ui| { + // Only a link that still works is worth sharing again. if let Some(link) = &participant.invite + && link.expires > now && ui.small_button("Show link").clicked() { show = Some((name.clone(), link.clone())); @@ -1439,7 +1450,7 @@ impl Panels { if ui .small_button("Remove from scenes") .on_hover_text( - "Take their camera and screen, and any mask reading them, out of every scene and mute them. They stay connected until their link expires.", + "Take their camera and screen, and any mask reading them, out of every scene and mute them. Their token stops renewing, so they leave within --guest-expiry. Undo puts them back in the scenes; only New link renews them again.", ) .clicked() { @@ -1461,6 +1472,10 @@ impl Panels { } } if let Some(name) = remove { + if let Err(err) = room.stop_renewing(&name) { + self.report(Err(err)); + return; + } let mut state = engine.state.lock().unwrap(); let mut draft = state.collection().draft(format!("Remove `{name}` from scenes")); let result = match room.take_off(&mut draft, &name) { diff --git a/sh/upgrade-moq.sh b/sh/upgrade-moq.sh index 2b7ce0f..bead1d8 100755 --- a/sh/upgrade-moq.sh +++ b/sh/upgrade-moq.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Move the moq submodule to a moq commit (default: origin/main) and refresh -# Cargo.lock to match. Commit the gitlink and Cargo.lock together. +# Cargo.lock and bun.lock to match. Commit the gitlink and both locks together. set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -10,4 +10,6 @@ git -C "$root/moq" fetch --quiet origin main git -C "$root/moq" checkout --quiet --detach "$rev" # Resolve without --locked: Cargo adds and prunes only what the new moq needs. cargo metadata --manifest-path "$root/Cargo.toml" --format-version 1 >/dev/null +# The join page's workspace includes moq's JS packages, whose dependencies move too. +(cd "$root" && bun install) echo "moq -> $(git -C "$root/moq" log --oneline -1)"