From f61380aefb28bbd030bafbad5c402773baf76bf5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 18:13:42 -0800 Subject: [PATCH 01/12] chore(quest): claim quest/m0/guest-refresh.md Quest-Claim: 735f2244e053282e From a5f69a84dd2d3258a054116a44b5ca9112b2e441 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 18:18:32 -0800 Subject: [PATCH 02/12] quest: guest token refresh waits on in-band renewal being released moq's main already renews a token on a live session, but the published @moq/net and moq-relay (moq's release branch) have neither the Auth handle nor in-band renewal. Add a waiting quest for that release, require it from guest-refresh, and record the delivery-track design found while scoping. Co-Authored-By: Claude Opus 5.5 --- quest/m0/README.md | 1 + quest/m0/auth-renewal-release.md | 16 ++++++++++++++++ quest/m0/guest-refresh.md | 32 +++++++++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 quest/m0/auth-renewal-release.md diff --git a/quest/m0/README.md b/quest/m0/README.md index 461ee6b..4595f1f 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -96,6 +96,7 @@ 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 +- [In-band token renewal is released](/quest/m0/auth-renewal-release.md) - the published `@moq/net` and relay renew a token on a live session, for guest token refresh - [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 diff --git a/quest/m0/auth-renewal-release.md b/quest/m0/auth-renewal-release.md new file mode 100644 index 0000000..8dbbe99 --- /dev/null +++ b/quest/m0/auth-renewal-release.md @@ -0,0 +1,16 @@ +# [XS] In-band token renewal is released + +## Goal + +A published `@moq/net` presents a fresh token on a live session +(`connection.auth.add(token)`, its `Auth` export), and a published +`moq-relay` renews grants in band, over a moq-lite version that is not a wip +wire. moq's `main` has both today; its `release` branch, which npm and +crates.io publish, has neither, and `main` reaches `release` only at +[moq's next breaking cut](https://github.com/moq-dev/moq/blob/main/quest/m2/release/cut.md), +which also finalizes moq-lite-07. + +Check: `npm view @moq/net version` is at or past that cut, and the package's +`index.d.ts` exports `Auth`. Advance it by bumping `js/join`'s `@moq/*` +dependencies to that release in its own PR, fixing whatever the new API +breaks. diff --git a/quest/m0/guest-refresh.md b/quest/m0/guest-refresh.md index 3f730f3..cab3032 100644 --- a/quest/m0/guest-refresh.md +++ b/quest/m0/guest-refresh.md @@ -13,7 +13,33 @@ A signed guest token cannot be revoked, and the relay ends a session at its 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 +Renew over the transport's own re-authentication; 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). + +Found 2026-11-03: moq's `main` already renews in band, so nothing lands in +moq first. The join page adds the renewed token to its connection's auth +handle (`connection.auth.add`), which presents it on the live session and on +every reconnect, and closes the old credential once the new one's grant +arrives; the relay verifies it through its auth server +([in-band renewal](https://github.com/moq-dev/moq/blob/main/doc/bin/relay/auth.md#in-band-renewal)). +It needs moq-lite-07 or moq-transport draft-18+ with MoQ Auth on both the +page and the relay, which is what Required waits on. + +Recommendations for the delivery track: + +- moq-room's claims let every guest subscribe to `**`, so any guest could read + another's renewal. Sign guests a narrower subscribe grant that covers their + own token path, plus what the [return feed](/quest/m0/guest-return.md) + publishes for them. +- The studio's own `--connect` session need not publish in the room. Publish + the token broadcasts over a session dialed with a token the room key signs + for those paths alone. +- Each renewal lasts one `--guest-expiry` and goes out well before the + current `exp`, so a removed or departed guest stays bounded by one expiry. + The studio holds at most one token broadcast per guest it signed for. + +## Required + +- [In-band token renewal is released](/quest/m0/auth-renewal-release.md) - the join page's `@moq/net` and the relay renew a token on a live session From 1afe9b45ca61fc0b88fa883beb422f59a874fb9a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 18:27:54 -0800 Subject: [PATCH 03/12] build(join): build the join page on the moq submodule's JS packages The join page took @moq/* from npm, which publishes moq's release branch, so any studio feature needing newer moq JS waited on a release cut. Its @moq/* dependencies are now workspace links into moq/js, like the Rust crates' path dependencies: the workspace lists every moq package they reach, installs hoisted as moq does so the submodule's worklet plugin resolves its tools, and builds @moq/libav on install. The join page's deploy checks out the submodule and redeploys when the gitlink moves, and `just upgrade-moq` refreshes bun.lock too. Drop the waiting quest for a moq release, which this makes moot. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/join.yml | 4 + README.md | 4 +- bun.lock | 373 +++++++++++++++++++++++++++++-- bunfig.toml | 4 + js/join/package.json | 9 +- js/join/tsconfig.json | 4 +- js/join/vite.config.ts | 7 + justfile | 2 +- package.json | 23 +- quest/m0/README.md | 1 - quest/m0/auth-renewal-release.md | 16 -- quest/m0/guest-refresh.md | 4 - sh/upgrade-moq.sh | 4 +- 13 files changed, 398 insertions(+), 57 deletions(-) create mode 100644 bunfig.toml delete mode 100644 quest/m0/auth-renewal-release.md 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/README.md b/README.md index 599e07d..014e690 100644 --- a/README.md +++ b/README.md @@ -447,7 +447,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..35032ec 100644 --- a/bun.lock +++ b/bun.lock @@ -12,13 +12,276 @@ "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", + "rimraf": "^6.1.3", + "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/mini": "^4.6.7", + "zod": "^4.6.7", + }, + "devDependencies": { + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "rimraf": "^6.1.3", + "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", + "rimraf": "^6.1.3", + "typescript": "7.0.2", + }, + "peerDependencies": { + "@zod/mini": "^4.5.0", + "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", + "rimraf": "^6.1.3", + "typescript": "7.0.2", + }, + }, + "moq/js/msf": { + "name": "@moq/msf", + "version": "0.3.1", + "dependencies": { + "@moq/json": "workspace:^", + "@moq/net": "workspace:^", + "@zod/mini": "^4.6.7", + "zod": "^4.6.7", + }, + "devDependencies": { + "rimraf": "^6.1.3", + "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:*", + "async-mutex": "^0.5.0", + "pako": "^3.0.2", + "sorted-btree": "^2.1.0", + "uuid": "^14.0.3", + }, + "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", + "rimraf": "^6.1.3", + "typescript": "7.0.2", + }, + "peerDependencies": { + "@zod/mini": "^4.5.0", + "zod": "^4.5.0", + }, + }, + "moq/js/pattern": { + "name": "@moq/pattern", + "version": "0.2.1", + "devDependencies": { + "@types/bun": "^1.4.4", + "rimraf": "^6.1.3", + "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", + "rimraf": "^6.1.3", + "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", + "rimraf": "^6.1.3", + "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/mini": "^4.6.7", + "zod": "^4.6.7", + }, + "devDependencies": { + "@types/audioworklet": "^0.0.100", + "@types/bun": "^1.4.4", + "@typescript/lib-dom": "npm:@types/web@^0.0.358", + "rimraf": "^6.1.3", + "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", + "rimraf": "^6.1.3", + "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", + "rimraf": "^6.1.3", + "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", + "rimraf": "^6.1.3", + "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", + "rimraf": "^6.1.3", + "rolldown": "^1.2.15", "typescript": "7.0.2", "vite": "^8.3.5", }, @@ -47,39 +310,45 @@ "@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=="], + + "@isaacs/nonrecursive-glob-match": ["@isaacs/nonrecursive-glob-match@1.0.3", "", {}, "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA=="], + "@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/video": ["@moq/video@workspace:moq/js/video"], - "@moq/web-socket-stream": ["@moq/web-socket-stream@0.1.1", "", {}, "sha512-VKi/GE/CLqmbEZlgNbBLbAFk2pQTxT+KUD2DjCSoxqZxbS3KBe5BBZ0m3fila4fEGyll2JT5MmgEyVr4zkZEVA=="], + "@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 +384,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 +394,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=="], @@ -173,16 +450,30 @@ "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=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "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=="], + "glob": ["glob@14.0.3", "", { "dependencies": { "minimatch": "^11.0.2", "minipass": "^7.1.3", "path-scurry": "^3.0.0" } }, "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow=="], + + "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=="], @@ -207,22 +498,50 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + "media-captions": ["media-captions@0.0.18", "", {}, "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ=="], "mediabunny": ["mediabunny@1.65.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-TNnDNOmD5Fme4rwMGWuNv485a9iQwd9lgw88PI9zQ0FMnzEUoo6EJpG7ZhdJ2iwZFWo8AzNkP1KPq5IAHaktKw=="], + "minimatch": ["minimatch@11.0.4", "", { "dependencies": { "@isaacs/nonrecursive-glob-match": "^1.0.3", "brace-expansion": "^5.0.12" } }, "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@3.0.3", "", {}, "sha512-tmrebBBYI5FOw8QICGwJNqE8fotC8R+gwcxOvoFNv+GKehOI6Lc6YBolNcQNnC4v96l1wt7rh102ifD4kVeZAQ=="], + "path-scurry": ["path-scurry@3.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "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=="], + + "rimraf": ["rimraf@6.1.4", "", { "dependencies": { "glob": "^14.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-Fk3reQDVPZUMbEm7Zwxz03BWQMW3V5buSk9aU8OckTrO8A1nQtbqvYsAantGNermwsIsXcuOBqcoJ3HuMELctw=="], + "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=="], @@ -231,12 +550,18 @@ "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=="], + + "uuid": ["uuid@14.0.3", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw=="], "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/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 dd34f2e..86b35e3 100644 --- a/justfile +++ b/justfile @@ -56,7 +56,7 @@ render-sw: cargo nextest run --locked -p moq-scene --lib --run-ignored only -E 'test(/^compositor::tests::/)' cargo nextest run --locked -p moq-studio --features openh264 --run-ignored only -E 'test(=engine::tests::the_program_reaches_a_relay) | test(=setup::tests::times_every_canvas)' -# 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 4595f1f..461ee6b 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -96,7 +96,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 -- [In-band token renewal is released](/quest/m0/auth-renewal-release.md) - the published `@moq/net` and relay renew a token on a live session, for guest token refresh - [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 diff --git a/quest/m0/auth-renewal-release.md b/quest/m0/auth-renewal-release.md deleted file mode 100644 index 8dbbe99..0000000 --- a/quest/m0/auth-renewal-release.md +++ /dev/null @@ -1,16 +0,0 @@ -# [XS] In-band token renewal is released - -## Goal - -A published `@moq/net` presents a fresh token on a live session -(`connection.auth.add(token)`, its `Auth` export), and a published -`moq-relay` renews grants in band, over a moq-lite version that is not a wip -wire. moq's `main` has both today; its `release` branch, which npm and -crates.io publish, has neither, and `main` reaches `release` only at -[moq's next breaking cut](https://github.com/moq-dev/moq/blob/main/quest/m2/release/cut.md), -which also finalizes moq-lite-07. - -Check: `npm view @moq/net version` is at or past that cut, and the package's -`index.d.ts` exports `Auth`. Advance it by bumping `js/join`'s `@moq/*` -dependencies to that release in its own PR, fixing whatever the new API -breaks. diff --git a/quest/m0/guest-refresh.md b/quest/m0/guest-refresh.md index cab3032..e451f8a 100644 --- a/quest/m0/guest-refresh.md +++ b/quest/m0/guest-refresh.md @@ -39,7 +39,3 @@ Recommendations for the delivery track: - Each renewal lasts one `--guest-expiry` and goes out well before the current `exp`, so a removed or departed guest stays bounded by one expiry. The studio holds at most one token broadcast per guest it signed for. - -## Required - -- [In-band token renewal is released](/quest/m0/auth-renewal-release.md) - the join page's `@moq/net` and the relay renew a token on a live session 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)" From ee34030ca4ea722bcdedff1dadf62056e9143113 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 18:47:45 -0800 Subject: [PATCH 04/12] feat(studio): renew a joined guest's token while they stay in the room A signed guest token cannot be revoked and the relay ends a session at its expiry, so a show could not outlast --guest-expiry. While a guest is in the room, the studio now signs them a fresh token every half --guest-expiry and publishes it at `.renew/`, over a session of its own in the room that the room key signs for and that renews its own token the same way. A guest's token subscribes only to its own renewals, so no guest reads another's. The join page offers moq-lite-07, carries its token in SETUP rather than the URL, and presents each renewal in band without reconnecting, storing it so a reload rejoins with it. Remove from scenes stops a guest's renewals, so their last token bounds how long they stay; a new link resumes them. The Guests panel reports when the renewing session cannot connect. Co-Authored-By: Claude Opus 5.5 --- README.md | 22 +- js/join/src/index.ts | 16 +- js/join/src/link.test.ts | 10 +- js/join/src/link.ts | 12 +- js/join/src/renew.test.ts | 39 ++++ js/join/src/renew.ts | 56 +++++ quest/m0/README.md | 1 - quest/m0/guest-refresh.md | 41 ---- quest/m0/guest-return.md | 4 +- rs/moq-studio/Cargo.toml | 2 + rs/moq-studio/src/guests.rs | 405 +++++++++++++++++++++++++++++++++--- rs/moq-studio/src/main.rs | 8 +- rs/moq-studio/src/ui.rs | 9 +- 13 files changed, 534 insertions(+), 91 deletions(-) create mode 100644 js/join/src/renew.test.ts create mode 100644 js/join/src/renew.ts delete mode 100644 quest/m0/guest-refresh.md diff --git a/README.md b/README.md index 014e690..2174d47 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,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 \ @@ -164,8 +168,12 @@ 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`) 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 who leaves needs a link, or a renewed token, that has + not expired to come back. The Guests panel says when renewal is failing. - 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 @@ -177,7 +185,8 @@ 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. +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 until it expires. A guest's broadcasts become sources named after them (`alice`, `alice screen`) when they first appear, muted and in no scene: place them from @@ -189,8 +198,9 @@ 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`. **New +link** lets them back in and renews them again. ## Scene file diff --git a/js/join/src/index.ts b/js/join/src/index.ts index f45ac4d..e3d17e2 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,18 @@ 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, link.name, (jwt) => { + tokens.set([jwt]); + // So a reload rejoins with the newest token, after the link's has expired. + stored((storage) => storage.setItem(STORED, format({ ...link, jwt }))); + }); + 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..85dd12a 100644 --- a/js/join/src/link.test.ts +++ b/js/join/src/link.test.ts @@ -1,19 +1,23 @@ 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", () => { +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" })); 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"); + // 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" })); expect(link.name).toBe("Alice Smith"); + expect(parse(format(link)).name).toBe("Alice Smith"); }); test("an incomplete or malformed link is refused", () => { diff --git a/js/join/src/link.ts b/js/join/src/link.ts index 455cd40..43f6cc2 100644 --- a/js/join/src/link.ts +++ b/js/join/src/link.ts @@ -8,10 +8,12 @@ /** 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; } /** Parse a join link's fragment, without its `#`. Throws on a link the studio did not make. */ @@ -35,6 +37,10 @@ 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 }; + return { url, name, jwt }; +} + +/** The fragment {@link parse} reads `link` back from. */ +export function format(link: Link): string { + return new URLSearchParams({ url: link.url.href, name: link.name, jwt: link.jwt }).toString(); } diff --git a/js/join/src/renew.test.ts b/js/join/src/renew.test.ts new file mode 100644 index 0000000..033e5cd --- /dev/null +++ b/js/join/src/renew.test.ts @@ -0,0 +1,39 @@ +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", async () => { + const origin = new Net.Origin.Producer(); + const tokens = new Signal([]); + const renewal = follow(new Signal(origin), "alice", (token) => + tokens.update((all) => [...all, token]), + ); + + const other = origin.createBroadcast(path("bob")); + other.broadcast.createTrack("token").writeFrame({ payload: encode("bob's") }); + other.announce(); + + const publication = origin.createBroadcast(path("alice")); + 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(); +}); diff --git a/js/join/src/renew.ts b/js/join/src/renew.ts new file mode 100644 index 0000000..b4bbfde --- /dev/null +++ b/js/join/src/renew.ts @@ -0,0 +1,56 @@ +/** + * 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: `RENEW` in `rs/moq-studio/src/guests.rs`. */ +export function path(name: string): Net.Path.Valid { + return Net.Path.from(`.renew/${name}`); +} + +/** 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 for `name` in `origin`, across + * the studio's restarts, until the returned effect is closed. + */ +export function follow( + origin: Getter, + name: string, + renewed: (token: string) => void, +): Effect { + const signals = new Effect(); + signals.run((effect) => { + const reader = effect.get(origin); + if (!reader) return; + const request = reader.follow(path(name)); + 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; + const token = await effect.race(group.readString()); + if (token) renewed(token); + } + }); + }); + }); + return signals; +} diff --git a/quest/m0/README.md b/quest/m0/README.md index 461ee6b..ede3612 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -96,7 +96,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 e451f8a..0000000 --- a/quest/m0/guest-refresh.md +++ /dev/null @@ -1,41 +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; 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). - -Found 2026-11-03: moq's `main` already renews in band, so nothing lands in -moq first. The join page adds the renewed token to its connection's auth -handle (`connection.auth.add`), which presents it on the live session and on -every reconnect, and closes the old credential once the new one's grant -arrives; the relay verifies it through its auth server -([in-band renewal](https://github.com/moq-dev/moq/blob/main/doc/bin/relay/auth.md#in-band-renewal)). -It needs moq-lite-07 or moq-transport draft-18+ with MoQ Auth on both the -page and the relay, which is what Required waits on. - -Recommendations for the delivery track: - -- moq-room's claims let every guest subscribe to `**`, so any guest could read - another's renewal. Sign guests a narrower subscribe grant that covers their - own token path, plus what the [return feed](/quest/m0/guest-return.md) - publishes for them. -- The studio's own `--connect` session need not publish in the room. Publish - the token broadcasts over a session dialed with a token the room key signs - for those paths alone. -- Each renewal lasts one `--guest-expiry` and goes out well before the - current `exp`, so a removed or departed guest stays bounded by one expiry. - The studio holds at most one token broadcast per guest it signed for. diff --git a/quest/m0/guest-return.md b/quest/m0/guest-return.md index 78e8625..e89de61 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 ac9c56a..eccefb4 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -66,6 +66,8 @@ 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"] } # The auth server a test relay verifies guest tokens with. moq-auth = { workspace = true, features = ["serve"] } moq-relay = { workspace = true, features = ["test-support"] } diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs index c96809c..6a0bc60 100644 --- a/rs/moq-studio/src/guests.rs +++ b/rs/moq-studio/src/guests.rs @@ -9,10 +9,17 @@ //! 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. +//! +//! 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 that +//! guest's token may subscribe to, and the join page presents it in band. +//! Removing a guest from scenes stops their renewals, so their last token bounds +//! how long they stay. use std::collections::BTreeMap; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use anyhow::Context; @@ -26,6 +33,14 @@ use crate::engine::Engine; /// The domain `.github/workflows/join.yml` deploys `js/join` to. const JOIN_PAGE: &str = "https://join.studio.moq.dev/"; +/// Where each guest's renewed tokens are published: `.renew/`, which +/// moq-room's announcements hide. `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 guest flags. #[derive(usage_rs::Args, Clone, Debug)] #[usage(unknown_flags = "error", args_override_self = false)] @@ -48,8 +63,9 @@ 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. #[usage(long, default = "4h")] pub guest_expiry: moq_tokio::cli::Duration, } @@ -86,6 +102,10 @@ pub struct Room { page: Url, expiry: Duration, participants: Mutex>, + /// 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 +117,18 @@ 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. + pub removed: bool, +} + +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. @@ -161,9 +193,13 @@ impl Room { page, expiry, participants: Mutex::default(), + changed: Default::default(), + session: Default::default(), }; - // Sign once now, so a key that cannot sign for guests fails at startup. + // Sign once now, so a key that cannot sign for guests or for the studio's + // own session fails at startup. room.sign("guest")?; + room.sign_session()?; Ok(room) } @@ -198,32 +234,72 @@ impl Room { .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()); + let mut participants = self.participants.lock().unwrap(); + let participant = participants.entry(name.to_string()).or_default(); + participant.invite = Some(invite.clone()); + // A new link lets a removed guest back in. + participant.removed = false; + drop(participants); + self.changed.notify_one(); Ok(invite) } - /// A token for `identity`, and when it expires. + /// Stop renewing the token of the guest called `identity`, until the host + /// makes them a new link. + pub fn stop_renewing(&self, identity: &str) { + let mut participants = self.participants.lock().unwrap(); + participants.entry(identity.to_string()).or_default().removed = true; + drop(participants); + self.changed.notify_one(); + } + + /// 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) -> anyhow::Result<(String, kio::clock::Wall)> { + let renewals = moq_auth::Pattern::literal(&format!("{RENEW}/{identity}")) + .with_context(|| format!("cannot invite `{identity}`"))?; + let claims = moq_room::claims(self.root.as_str(), identity) + .with_context(|| format!("cannot invite `{identity}`"))? + .with_subscribe([renewals]); + 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 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. 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 token = self.key.sign(&claims.with_issued(issued).with_expires(expires))?; Ok((token, expires)) } + /// The guests whose tokens are renewed. + fn renewed(&self) -> Vec { + let participants = self.participants.lock().unwrap(); + let renewed = participants.iter().filter(|(_, participant)| participant.renewed()); + renewed.map(|(identity, _)| identity.clone()).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 +334,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 +495,149 @@ 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 { + 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, _| renewed.contains(identity)); + for identity in renewed { + if self.tracks.contains_key(&identity) { + continue; + } + match self.publish(&identity) { + 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`, holding a fresh token. + fn publish(&self, identity: &str) -> anyhow::Result { + let publication = self.origin.create_broadcast(format!("{RENEW}/{identity}"))?; + let track = publication.create_track(TOKEN_TRACK, None)?; + let mut renewed = Renewed { 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.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> { @@ -616,7 +837,10 @@ mod tests { 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".parse().unwrap()].into_iter().collect() + ); let (issued, expires) = (claims.issued.unwrap(), claims.expires.unwrap()); assert_eq!(expires.saturating_duration_since(issued), EXPIRY); assert_eq!(invite.expires, expires); @@ -668,6 +892,7 @@ mod tests { invite: None, camera: true, screen: false, + removed: false, } ); @@ -698,9 +923,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 +954,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 +982,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, 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 +1029,98 @@ mod tests { path: "guests/alice/camera.hang".into() } ); + + let token = kio::clock::timeout(TIMEOUT, next_token(&renewals.consume(), "alice")) + .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(format!("{RENEW}/bob"), 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) -> anyhow::Result { + let broadcast = origin.follow(format!("{RENEW}/{identity}"), 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 one `--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()); + 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").await.unwrap()).unwrap(); + assert_eq!(first.root, "show/guests"); + assert_eq!(first.publish, ["alice/**".parse().unwrap()].into_iter().collect()); + assert_eq!( + first.subscribe, + [".renew/alice".parse().unwrap()].into_iter().collect(), + "a guest reads only its own renewals" + ); + assert_eq!( + first.expires.unwrap().saturating_duration_since(first.issued.unwrap()), + EXPIRY + ); + + clock.advance(EXPIRY / 2); + renewals.renew(); + let second = key.verify(&next_token(&consumer, "alice").await.unwrap()).unwrap(); + assert_eq!( + second + .expires + .unwrap() + .saturating_duration_since(first.expires.unwrap()), + EXPIRY / 2 + ); + + room.stop_renewing("alice"); + assert!(room.participants()["alice"].removed); + renewals.sync(); + assert!(renewals.tracks.is_empty(), "a removed guest's renewals end"); + + room.invite("alice").unwrap(); + renewals.sync(); + next_token(&consumer, "alice").await.unwrap(); + + join(false); + renewals.sync(); + assert!(renewals.tracks.is_empty(), "a guest who left is not renewed"); } /// 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 4c649f2..93e2b97 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -130,13 +130,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 8a24348..ced6cd0 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -1188,6 +1188,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."); @@ -1227,7 +1233,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 unless you send a new link.", ) .clicked() { @@ -1249,6 +1255,7 @@ impl Panels { } } if let Some(name) = remove { + room.stop_renewing(&name); 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) { From 4883616ff8b0b63d8dd15106c6446d2a24b9f00e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 18:53:33 -0800 Subject: [PATCH 05/12] fix(join): rejoin with the newest renewed token after Leave Co-Authored-By: Claude Opus 5.5 --- js/join/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/join/src/index.ts b/js/join/src/index.ts index e3d17e2..e87033e 100644 --- a/js/join/src/index.ts +++ b/js/join/src/index.ts @@ -61,8 +61,9 @@ function join(link: Link): () => void { const renewal = Renew.follow(connection.origin, link.name, (jwt) => { tokens.set([jwt]); - // So a reload rejoins with the newest token, after the link's has expired. - stored((storage) => storage.setItem(STORED, format({ ...link, 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()); From c9e7e921c04a4f2025aac68e66cc8ec73e1e96b3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 19:39:08 -0800 Subject: [PATCH 06/12] fix(studio): encrypt guest renewals, keep removals, and renew before expiry Address review of #28: - A link signed before renewals existed subscribes to the whole room, as can anything else the relay lets subscribe there, so a path grant alone could not keep renewals private. Each guest's renewals are now encrypted with moq-e2ee to a key derived from the room key and carried only in that guest's links, and published under a name only that key derives. The join page decrypts them with the key from its link. - Removals from scenes are kept beside the scene file (`show.guests.toml`), so a restart does not renew a removed guest until a new link. - Tokens expire rounded up to the whole second, and --guest-expiry must be at least a minute, so a renewal every half expiry always reaches a guest, and the studio's own session, before the token it replaces expires. - The Guests panel shows a renewed guest as renewing rather than their link's expiry, offers Show link only while the link works, and says undo does not resume renewal; the README says a link holder stays while renewed. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 260 ++++++++++++++++++++++++ Cargo.toml | 2 + README.md | 26 ++- bun.lock | 16 ++ js/join/package.json | 1 + js/join/src/index.ts | 2 +- js/join/src/link.test.ts | 17 +- js/join/src/link.ts | 15 +- js/join/src/renew.test.ts | 45 +++-- js/join/src/renew.ts | 66 ++++--- package.json | 1 + rs/moq-studio/Cargo.toml | 7 + rs/moq-studio/src/guests.rs | 385 +++++++++++++++++++++++++++++------- rs/moq-studio/src/main.rs | 5 +- rs/moq-studio/src/ui.rs | 12 +- 15 files changed, 728 insertions(+), 132 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d603dd6..40d39f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,6 +482,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -912,6 +914,17 @@ dependencies = [ "libc", ] +[[package]] +name = "core-models" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3344d349528f449816cfa85e558c439bdca5a6d8a738cfb21e69f0b2b1952f" +dependencies = [ + "hax-lib 0.3.7", + "pastey", + "rand", +] + [[package]] name = "core_affinity" version = "0.8.3" @@ -983,6 +996,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crabgrind" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "035c801dc4b418e1d857a6536dc88b4d1929c7a1fd163389766e18d322d34a84" +dependencies = [ + "bindgen 0.72.1", + "cc", + "pkg-config", + "rustversion", +] + [[package]] name = "crc" version = "3.4.0" @@ -1089,6 +1114,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.24.1" @@ -2254,6 +2304,79 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "hax-lib" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a33cb227f97ee5c419064c31d7c55a7d895f28d8019ccdadc311cc036500c4" +dependencies = [ + "hax-lib-macros 0.3.7", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3bfcfc915e894c331662e4ac76706b777a5956535ea24535f4df30da04b1bf" +dependencies = [ + "hax-lib-macros 0.4.2", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28da835a5f153e9f3d0cc1f42ee605a1cfa9093c93348193793d60a1b0908b51" +dependencies = [ + "hax-lib-macros-types 0.3.7", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hax-lib-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9010990fcba1d1494047fa433a433be0a5df23653a9784c739f7bba270272e" +dependencies = [ + "hax-lib-macros-types 0.4.2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed327e9a28d6ee018a4a9ba842d4ee27e378c8a591d2beb6f42f32b24a02fcb" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e053e3a5aeb92df47b07323180c5088edc488ca49883a234f6c73255c54cc520" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "heck" version = "0.5.0" @@ -2937,6 +3060,86 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libcrux-aes" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd415025511a0bd8731cae34ae2d5e07c1b3f0443d5c12bd199c9e9b2f1fd4d5" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ce64ead37fc67c1039fdbcf1e8c97044e6a644cb3d1f79060b5c9ac8fdfe0a" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de82722c9c7419a311e84ee902e9643a15e39a6fcaade87f21e94f8d0f30fa70" +dependencies = [ + "core-models", + "hax-lib 0.4.2", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2afdd4da30d6e23f76bd6f3ad61be5c773720349eef3e791f8b79cfea3fe1436" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf8bee8c35d34867a3762d6ee7b74e8523a882855c3cfce30484e9554342206" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d727ee28ee0d5d2661daa5f2f47965cd3489a0ac9130f3410e95632a98b12e49" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c4ba47b3596f2771213ff162c792e9b93ddc0cdd178a2e407e00bb8ae4afcd" +dependencies = [ + "crabgrind", + "hax-lib 0.4.2", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8958bb5c3780eb7c3355aeeb051629b830e47bfefd36befed93c81b53366bbc4" +dependencies = [ + "libcrux-secrets", + "rand", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3228,6 +3431,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "moq-e2ee" +version = "0.0.11" +dependencies = [ + "base64 0.23.1", + "bytes", + "kio", + "moq-net", + "rind", + "thiserror 2.0.21", + "zeroize", +] + [[package]] name = "moq-json" version = "0.5.8" @@ -3475,6 +3691,7 @@ dependencies = [ "kio", "moq-audio", "moq-auth", + "moq-e2ee", "moq-json", "moq-mux", "moq-net", @@ -3486,8 +3703,11 @@ dependencies = [ "moq-video", "pollster", "qrcodegen", + "rind", + "serde", "tempfile", "tokio", + "toml", "tracing", "url", "usage-rs", @@ -4863,6 +5083,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -5283,6 +5525,24 @@ dependencies = [ "hmac", ] +[[package]] +name = "rind" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422500e50252ad3bd319f359c54431441711a8fd55cb228973dde123ff8c2039" +dependencies = [ + "cfg-if", + "curve25519-dalek", + "fiat-crypto", + "getrandom 0.4.3", + "libc", + "libcrux-aes", + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-traits", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/Cargo.toml b/Cargo.toml index b4cb309..942cf66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ allow_attributes_without_reason = "warn" hang = { path = "moq/rs/hang" } kio = { path = "moq/rs/kio" } moq-auth = { path = "moq/rs/moq-auth", default-features = false } +moq-e2ee = { path = "moq/rs/moq-e2ee" } moq-audio = { path = "moq/rs/moq-audio", default-features = false } moq-json = { path = "moq/rs/moq-json" } moq-mux = { path = "moq/rs/moq-mux" } @@ -43,6 +44,7 @@ anyhow = "1" bytes = "1" humantime-serde = "1.1.1" pollster = "1.0" +rind = "0.1.2" serde = { version = "1", features = ["derive"] } serde_with = "3" tempfile = "3" diff --git a/README.md b/README.md index 966787f..14d2e32 100644 --- a/README.md +++ b/README.md @@ -167,12 +167,15 @@ 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, 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 who leaves needs a link, or a renewed token, that has - not expired to come back. The Guests panel says when renewal is failing. +- `--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. Each renewal is encrypted to a key only that guest's + links carry, so nothing else that can subscribe in the room, a link from an + older studio included, can read it. 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. - 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 @@ -186,8 +189,9 @@ 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, and read only their own renewals. -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`, +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 @@ -198,8 +202,10 @@ who reconnects after you removed their source comes back under a new name **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 it also stops -renewing theirs, and they are disconnected within `--guest-expiry`. **New -link** lets them back in and renews them again. +renewing theirs, and they are disconnected within `--guest-expiry`. That +holds across a restart: removed guests are kept beside the scene file, as +`show.guests.toml` for `show.toml`. Undo puts them back in the scenes but not +on renewal; **New link** lets them back in and renews them again. ## Scene file diff --git a/bun.lock b/bun.lock index 35032ec..e63d424 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "name": "@moq/studio-join", "version": "0.0.0", "dependencies": { + "@moq/e2ee": "workspace:*", "@moq/net": "workspace:*", "@moq/publish": "workspace:*", "@moq/room": "workspace:*", @@ -33,6 +34,19 @@ "typescript": "7.0.2", }, }, + "moq/js/e2ee": { + "name": "@moq/e2ee", + "version": "0.0.0", + "dependencies": { + "@moq/net": "workspace:^", + }, + "devDependencies": { + "@moq/clock": "workspace:^", + "@types/bun": "^1.4.4", + "rimraf": "^6.1.3", + "typescript": "7.0.2", + }, + }, "moq/js/hang": { "name": "@moq/hang", "version": "0.5.2", @@ -320,6 +334,8 @@ "@moq/clock": ["@moq/clock@workspace:moq/js/clock"], + "@moq/e2ee": ["@moq/e2ee@workspace:moq/js/e2ee"], + "@moq/hang": ["@moq/hang@workspace:moq/js/hang"], "@moq/json": ["@moq/json@workspace:moq/js/json"], diff --git a/js/join/package.json b/js/join/package.json index a08eb16..2993722 100644 --- a/js/join/package.json +++ b/js/join/package.json @@ -13,6 +13,7 @@ "test": "bun test" }, "dependencies": { + "@moq/e2ee": "workspace:*", "@moq/net": "workspace:*", "@moq/publish": "workspace:*", "@moq/room": "workspace:*", diff --git a/js/join/src/index.ts b/js/join/src/index.ts index e87033e..3762cfe 100644 --- a/js/join/src/index.ts +++ b/js/join/src/index.ts @@ -59,7 +59,7 @@ function join(link: Link): () => void { const connection = new Net.Connection({ url: link.url, versions: [Renew.VERSION], tokens, enabled: true }); signals.cleanup(() => connection.close()); - const renewal = Renew.follow(connection.origin, link.name, (jwt) => { + const renewal = Renew.follow(connection.origin, Renew.credential(link.name, link.key), (jwt) => { tokens.set([jwt]); // So joining again, or a reload, uses the newest token once the link's has expired. link.jwt = jwt; diff --git a/js/join/src/link.test.ts b/js/join/src/link.test.ts index 85dd12a..7ab8f99 100644 --- a/js/join/src/link.test.ts +++ b/js/join/src/link.test.ts @@ -3,31 +3,40 @@ import { format, parse } from "./link.ts"; const fragment = (params: Record) => new URLSearchParams(params).toString(); +const KEY = "00ff".repeat(16); + 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" })); + const link = parse( + fragment({ url: "https://relay.example:4443/show/guests", name: "alice", jwt: "a.b.c", key: KEY }), + ); expect(link.name).toBe("alice"); expect(link.url.origin).toBe("https://relay.example:4443"); expect(link.url.pathname).toBe("/show/guests"); expect(link.url.search).toBe(""); expect(link.jwt).toBe("a.b.c"); + expect(Array.from(link.key.slice(0, 2))).toEqual([0, 255]); + expect(link.key.length).toBe(32); // 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", key: KEY })); 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", key: KEY }; + for (const key of ["url", "name", "jwt", "key"] 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 key of ["00", KEY.toUpperCase(), `${KEY}00`, "zz".repeat(32)]) { + expect(() => parse(fragment({ ...valid, key }))).toThrow("key is invalid"); + } 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 43f6cc2..287c0c2 100644 --- a/js/join/src/link.ts +++ b/js/join/src/link.ts @@ -14,6 +14,8 @@ export interface Link { name: string; /** The token the studio signed for the guest: the link's, or a renewed one. */ jwt: string; + /** The 32-byte key that decrypts the guest's renewed tokens. */ + key: Uint8Array; } /** Parse a join link's fragment, without its `#`. Throws on a link the studio did not make. */ @@ -22,7 +24,8 @@ 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 hex = params.get("key"); + if (!relay || !name || !jwt || !hex) throw new Error("this join link is incomplete; ask the host for a new one"); let url: URL; try { @@ -37,10 +40,16 @@ export function parse(fragment: string): Link { throw new Error(`this join link names an invalid guest: ${name}`); } - return { url, name, jwt }; + const key = hex.match(/^[0-9a-f]{64}$/) + ? Uint8Array.from(hex.match(/../g) ?? [], (byte) => parseInt(byte, 16)) + : undefined; + if (!key) throw new Error("this join link's key is invalid; ask the host for a new one"); + + return { url, name, jwt, key }; } /** The fragment {@link parse} reads `link` back from. */ export function format(link: Link): string { - return new URLSearchParams({ url: link.url.href, name: link.name, jwt: link.jwt }).toString(); + const key = Array.from(link.key, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return new URLSearchParams({ url: link.url.href, name: link.name, jwt: link.jwt, key }).toString(); } diff --git a/js/join/src/renew.test.ts b/js/join/src/renew.test.ts index 033e5cd..c22d3b8 100644 --- a/js/join/src/renew.test.ts +++ b/js/join/src/renew.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "bun:test"; +import * as E2ee from "@moq/e2ee"; import * as Net from "@moq/net"; import { Signal } from "@moq/signals"; -import { follow, path } from "./renew.ts"; +import { credential, follow, path } from "./renew.ts"; const encode = (text: string) => new TextEncoder().encode(text); @@ -14,26 +15,44 @@ async function received(tokens: Signal, count: number): Promise { +/** Publish `name`'s renewals as the studio does, encrypted to `key`. */ +async function publish(origin: Net.Origin.Producer, name: string, key: Uint8Array) { + const studio = credential(name, key); + const generation = studio.produce(); + const publication = origin.createBroadcast(await path(studio)); + const track = await new E2ee.Broadcast.Producer(publication.broadcast, generation).createTrack("token"); + publication.announce({ epoch: generation.epoch }); + return async (token: string) => { + const group = track.appendGroup(); + await group.writeFrame(undefined, encode(token)); + group.finish(); + }; +} + +test("each token the studio publishes reaches the page, decrypted with its own key", async () => { const origin = new Net.Origin.Producer(); + const alice = new Uint8Array(32).fill(1); const tokens = new Signal([]); - const renewal = follow(new Signal(origin), "alice", (token) => + const renewal = follow(new Signal(origin), credential("alice", alice), (token) => tokens.update((all) => [...all, token]), ); - const other = origin.createBroadcast(path("bob")); - other.broadcast.createTrack("token").writeFrame({ payload: encode("bob's") }); - other.announce(); - - const publication = origin.createBroadcast(path("alice")); - const track = publication.broadcast.createTrack("token"); - track.writeFrame({ payload: encode("first") }); - publication.announce(); + await (await publish(origin, "bob", new Uint8Array(32).fill(2)))("bob's"); + const write = await publish(origin, "alice", alice); + await write("first"); expect(await received(tokens, 1)).toEqual(["first"]); - - track.writeFrame({ payload: encode("second") }); + await write("second"); expect(await received(tokens, 2)).toEqual(["first", "second"]); renewal.close(); origin.close(); }); + +test("a key that is not the guest's names another broadcast", async () => { + const right = await path(credential("alice", new Uint8Array(32).fill(1))); + // The same vector as `renewals_name_the_same_broadcast_as_the_join_page` in the studio. + expect(right).toBe(".renew/8W8-sbdtrkZZF_byr3vEcQ"); + expect(await path(credential("alice", new Uint8Array(32).fill(1)))).toBe(right); + expect(await path(credential("alice", new Uint8Array(32).fill(2)))).not.toBe(right); + expect(await path(credential("bob", new Uint8Array(32).fill(1)))).not.toBe(right); +}); diff --git a/js/join/src/renew.ts b/js/join/src/renew.ts index b4bbfde..d0a1dfc 100644 --- a/js/join/src/renew.ts +++ b/js/join/src/renew.ts @@ -1,53 +1,75 @@ /** * 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. + * them every half link lifetime, encrypted with `@moq/e2ee` to the key in their + * link, 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 E2ee from "@moq/e2ee"; import * as Net from "@moq/net"; -import { Effect, type Getter } from "@moq/signals"; +import { Effect, type Getter, Signal } 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: `RENEW` in `rs/moq-studio/src/guests.rs`. */ -export function path(name: string): Net.Path.Valid { - return Net.Path.from(`.renew/${name}`); -} +/** The prefix of every guest's renewals: `RENEW` in `rs/moq-studio/src/guests.rs`. */ +const PREFIX = ".renew"; -/** The track of those tokens, one group each: `TOKEN_TRACK` in `rs/moq-studio/src/guests.rs`. */ +/** The semantic broadcast and track names: `RENEW_BROADCAST` and `TOKEN_TRACK` there. */ +const BROADCAST = "renew"; const TRACK = "token"; +/** The credential that decrypts `name`'s renewals, from the 32-byte `key` in their link. */ +export function credential(name: string, key: Uint8Array): E2ee.Credential { + return new E2ee.Credential({ context: new TextEncoder().encode(name), kid: 0, secret: key }); +} + +/** Where the renewals `credential` decrypts are published. */ +export async function path(credential: E2ee.Credential): Promise { + return Net.Path.join(Net.Path.from(PREFIX), await credential.path(BROADCAST)); +} + /** - * Hand `renewed` each token the studio publishes for `name` in `origin`, across - * the studio's restarts, until the returned effect is closed. + * Hand `renewed` each token the studio publishes with `credential` in `origin`, + * across the studio's restarts, until the returned effect is closed. */ export function follow( origin: Getter, - name: string, + credential: E2ee.Credential, renewed: (token: string) => void, ): Effect { const signals = new Effect(); + const at = new Signal(undefined); + signals.spawn(async () => at.set(await path(credential))); + signals.run((effect) => { const reader = effect.get(origin); - if (!reader) return; - const request = reader.follow(path(name)); + const renewals = effect.get(at); + if (!reader || !renewals) 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()); + // moq-lite-07 carries the publisher's epoch, which keys its encryption. + const epoch = broadcast?.epoch; + if (!broadcast || !epoch) return; + const decrypted = new E2ee.Broadcast.Consumer(broadcast, credential.consume(epoch)); effect.spawn(async () => { - for (;;) { - const group = await effect.race(track.recvGroup()); - if (!group) return; - const token = await effect.race(group.readString()); - if (token) renewed(token); + const track = await effect.race(decrypted.subscribe(TRACK)); + if (!track) return; + try { + for (;;) { + const group = await effect.race(track.recvGroup()); + if (!group) return; + const frame = await effect.race(group.readFrame()); + if (frame) renewed(new TextDecoder().decode(frame.plaintext)); + } + } finally { + track.close(); } }); }); diff --git a/package.json b/package.json index 507a467..6affc63 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "workspaces": [ "js/join", "moq/js/clock", + "moq/js/e2ee", "moq/js/hang", "moq/js/json", "moq/js/libav", diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index eccefb4..cdec98d 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -47,6 +47,8 @@ kio = { workspace = true, features = ["async", "runtime"] } moq-audio = { workspace = true, features = ["bus"] } # Signs guest tokens with the room key. moq-auth = { workspace = true } +# Encrypts each guest's renewed tokens to the key in their link. +moq-e2ee = { workspace = true } moq-json = { workspace = true } moq-mux = { workspace = true } moq-net = { workspace = true } @@ -59,8 +61,13 @@ moq-video = { workspace = true } pollster = { workspace = true } # The join link's QR code. No dependencies of its own. qrcodegen = "1.8" +# Derives each guest's renewal key from the room key, as moq-e2ee does its own. +rind = { workspace = true } +# 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"] } diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs index 6a0bc60..b7b09d9 100644 --- a/rs/moq-studio/src/guests.rs +++ b/rs/moq-studio/src/guests.rs @@ -12,12 +12,13 @@ //! //! 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 that -//! guest's token may subscribe to, and the join page presents it in band. -//! Removing a guest from scenes stops their renewals, so their last token bounds -//! how long they stay. +//! `--guest-expiry` and publishes it under `.renew/`, encrypted with moq-e2ee to +//! a key only their link carries, at a path 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, so their last token +//! bounds how long they stay. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -33,14 +34,19 @@ use crate::engine::Engine; /// The domain `.github/workflows/join.yml` deploys `js/join` to. const JOIN_PAGE: &str = "https://join.studio.moq.dev/"; -/// Where each guest's renewed tokens are published: `.renew/`, which -/// moq-room's announcements hide. `js/join` reads the same path. +/// The prefix of each guest's renewals, which moq-room's announcements hide. +/// Beneath it, moq-e2ee names the broadcast after the guest's key. const RENEW: &str = ".renew"; -/// The track holding a guest's renewed tokens, one group each. `js/join` reads -/// the same name. +/// The semantic broadcast and track names of a guest's renewals, one token per +/// group. `js/join` reads the same names. +const RENEW_BROADCAST: &str = "renew"; 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)] @@ -65,15 +71,16 @@ pub struct Config { /// 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. + /// 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`. - pub fn load(&self, connect: Option<&Url>) -> anyhow::Result> { + /// `--room`, remembering who was removed from scenes beside the scene file + /// at `scenes`. + pub fn load(&self, connect: Option<&Url>, scenes: &std::path::Path) -> anyhow::Result> { let (room, key) = match (&self.room, &self.room_key, &self.join_page) { (None, None, None) => return Ok(None), (Some(room), Some(key), _) => (room, key), @@ -86,10 +93,36 @@ impl Config { let connect = connect.context("guests join through the relay; add --connect")?; 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_path(scenes))?)) } } +/// The moq-e2ee credential of `identity`'s renewals under `secret`, as +/// `credential` in `js/join/src/renew.ts` builds it. +fn credential(identity: &str, secret: [u8; moq_e2ee::SECRET_LEN]) -> anyhow::Result { + Ok(moq_e2ee::Credential::new(moq_e2ee::credential::Config { + context: bytes::Bytes::copy_from_slice(identity.as_bytes()), + kid: 0, + secret, + })?) +} + +/// Where the guests removed from scenes are kept: `show.guests.toml` for +/// `show.toml`, beside its history. +fn removals_path(scenes: &std::path::Path) -> PathBuf { + let mut name = scenes.file_stem().unwrap_or_default().to_os_string(); + name.push(".guests.toml"); + scenes.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. + removed: BTreeSet, +} + /// The studio's room: who was invited, and what each guest publishes. pub struct Room { /// The room, relative to the studio's connection. @@ -99,9 +132,13 @@ pub struct Room { /// Where a guest dials: the relay at the room, without the studio's credentials. url: Url, key: moq_auth::Key, + /// Derives each guest's renewal key, from the room key so it survives a restart. + renewals: rind::hmac::Key, 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. @@ -118,7 +155,7 @@ pub struct Participant { /// 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. + /// stops renewing their token, across restarts. pub removed: bool, } @@ -163,8 +200,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")?; @@ -185,14 +224,27 @@ impl Room { ); check_scope(&key, &root)?; + // The private key, which only the studio holds, so no one else can derive + // a guest's renewal key. + let secret = match &key.material { + moq_auth::KeyMaterial::EC { d: Some(d), .. } | moq_auth::KeyMaterial::OKP { d: Some(d), .. } => d, + moq_auth::KeyMaterial::RSA { + private: Some(private), .. + } => &private.d, + moq_auth::KeyMaterial::OCT { secret } => secret, + _ => anyhow::bail!("the room key has no private half, so it cannot sign guests' tokens"), + }; + let renewals = rind::hmac::Key::new(&rind::hmac::HMAC_SHA256, secret); let room = Self { path, root, url, key, + renewals, page, expiry, participants: Mutex::default(), + removals: None, changed: Default::default(), session: Default::default(), }; @@ -203,6 +255,41 @@ impl Room { 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; + } + self.removals = Some(path); + Ok(self) + } + + /// 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 removals = Removals { + removed: removed.map(|(identity, _)| identity.clone()).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 @@ -226,11 +313,13 @@ impl Room { // 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 secret: String = self.secret(name).iter().map(|byte| format!("{byte:02x}")).collect(); 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("key", &secret) .finish(); link.set_fragment(Some(&fragment)); let invite = Invite { link, expires }; @@ -238,19 +327,44 @@ impl Room { let participant = participants.entry(name.to_string()).or_default(); participant.invite = Some(invite.clone()); // A new link lets a removed guest back in. - participant.removed = false; + if std::mem::take(&mut participant.removed) { + self.save_removals(&participants)?; + } drop(participants); self.changed.notify_one(); Ok(invite) } - /// Stop renewing the token of the guest called `identity`, until the host - /// makes them a new link. - pub fn stop_renewing(&self, identity: &str) { + /// 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(); - participants.entry(identity.to_string()).or_default().removed = true; + let participant = participants.entry(identity.to_string()).or_default(); + if !std::mem::replace(&mut participant.removed, true) { + self.save_removals(&participants)?; + } drop(participants); self.changed.notify_one(); + Ok(()) + } + + /// The key that encrypts `identity`'s renewals, which only their links carry. + fn secret(&self, identity: &str) -> [u8; moq_e2ee::SECRET_LEN] { + let tag = rind::hmac::sign(&self.renewals, identity.as_bytes()); + tag.as_ref() + .try_into() + .expect("HMAC-SHA256 is as long as a moq-e2ee secret") + } + + /// The moq-e2ee credential of `identity`'s renewals. + fn credential(&self, identity: &str) -> anyhow::Result { + credential(identity, self.secret(identity)) + } + + /// Where `identity`'s renewals are published, in the room: a name only their + /// key derives. + fn renewal_path(credential: &moq_e2ee::Credential) -> anyhow::Result { + Ok(Path::new(RENEW)?.join(&credential.path(RENEW_BROADCAST)?)?) } /// Why the session publishing guests' renewed tokens is not connected, if it @@ -264,11 +378,11 @@ impl Room { /// paths but subscribes only to its own renewals, so no guest reads /// another's. fn sign(&self, identity: &str) -> anyhow::Result<(String, kio::clock::Wall)> { - let renewals = moq_auth::Pattern::literal(&format!("{RENEW}/{identity}")) - .with_context(|| format!("cannot invite `{identity}`"))?; + let invalid = || format!("cannot invite `{identity}`"); + let renewals = Self::renewal_path(&self.credential(identity).with_context(invalid)?).with_context(invalid)?; let claims = moq_room::claims(self.root.as_str(), identity) - .with_context(|| format!("cannot invite `{identity}`"))? - .with_subscribe([renewals]); + .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") } @@ -283,12 +397,15 @@ impl Room { .context("the room key cannot sign the studio's own session in the room") } - /// Sign `claims` to expire one `--guest-expiry` from now. + /// 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. + // 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 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)) } @@ -570,15 +687,17 @@ struct Renewals { tracks: BTreeMap, } -/// One guest's token track, ended when dropped. +/// One guest's token track, encrypted to their key, ended when dropped. struct Renewed { publication: moq_net::origin::Publication, - track: moq_net::track::Producer, + track: Option, } impl Drop for Renewed { fn drop(&mut self) { - let _ = self.track.finish(); + if let Some(track) = self.track.take() { + let _ = track.finish(); + } self.publication.close(); } } @@ -612,11 +731,18 @@ impl Renewals { /// The token track for `identity`, holding a fresh token. fn publish(&self, identity: &str) -> anyhow::Result { - let publication = self.origin.create_broadcast(format!("{RENEW}/{identity}"))?; - let track = publication.create_track(TOKEN_TRACK, None)?; - let mut renewed = Renewed { publication, track }; + let credential = self.room.credential(identity)?; + let generation = credential.produce(); + let publication = self.origin.create_broadcast(Room::renewal_path(&credential)?)?; + let protected = moq_e2ee::broadcast::Producer::new(publication.broadcast().clone(), generation.clone()); + let track = protected.create_track(TOKEN_TRACK, None)?; + let mut renewed = Renewed { + publication, + track: Some(track), + }; self.write(identity, &mut renewed)?; - renewed.publication.announce(Default::default())?; + let route = moq_net::origin::Route::default().with_epoch(generation.epoch().clone()); + renewed.publication.announce(route)?; Ok(renewed) } @@ -633,7 +759,10 @@ impl Renewals { fn write(&self, identity: &str, renewed: &mut Renewed) -> anyhow::Result<()> { let (token, _) = self.room.sign(identity)?; - renewed.track.write_frame(None, token)?; + let track = renewed.track.as_mut().expect("held until dropped"); + let mut group = track.append_group()?; + group.write_frame(None, token.as_bytes())?; + group.finish()?; Ok(()) } } @@ -683,6 +812,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("room.jwk"); key("show/guests", &["**"], &["**"]).to_file(&path).unwrap(); + let scenes = dir.path().join("show.toml"); let connect = Url::parse("https://relay.example/show").unwrap(); let config = |room: bool, key: bool, page: bool| Config { room: room.then(|| "guests".into()), @@ -691,23 +821,31 @@ mod tests { guest_expiry: EXPIRY.into(), }; - assert!(config(false, false, false).load(Some(&connect)).unwrap().is_none()); + assert!( + config(false, false, false) + .load(Some(&connect), &scenes) + .unwrap() + .is_none() + ); for partial in [ config(true, false, true), config(false, true, true), config(false, false, true), ] { - let err = partial.load(Some(&connect)).err().unwrap(); + let err = partial.load(Some(&connect), &scenes).err().unwrap(); assert!(err.to_string().contains("need both"), "{err}"); } - let err = config(true, true, true).load(None).err().unwrap(); + let err = config(true, true, true).load(None, &scenes).err().unwrap(); assert!(err.to_string().contains("--connect"), "{err}"); - let room = config(true, true, true).load(Some(&connect)).unwrap().unwrap(); + let room = config(true, true, true).load(Some(&connect), &scenes).unwrap().unwrap(); assert_eq!(room.path().as_str(), "guests"); let link = room.invite("alice").unwrap().link; assert!(link.as_str().starts_with("https://studio.example/join/#"), "{link}"); - let room = config(true, true, false).load(Some(&connect)).unwrap().unwrap(); + let room = config(true, true, false) + .load(Some(&connect), &scenes) + .unwrap() + .unwrap(); let link = room.invite("alice").unwrap().link; assert!(link.as_str().starts_with(JOIN_PAGE), "{link}"); } @@ -804,10 +942,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}"); @@ -837,12 +979,25 @@ mod tests { let claims = key.verify(&fragment["jwt"]).unwrap(); assert_eq!(claims.root, "show/guests"); assert_eq!(claims.publish, ["alice/**".parse().unwrap()].into_iter().collect()); + let renewals = Room::renewal_path(&room.credential("alice").unwrap()).unwrap(); assert_eq!( claims.subscribe, - [".renew/alice".parse().unwrap()].into_iter().collect() + [moq_auth::Pattern::literal(renewals.as_str()).unwrap()] + .into_iter() + .collect(), + "a guest reads only its own renewals" + ); + assert_eq!( + fragment["key"].len(), + moq_e2ee::SECRET_LEN * 2, + "the renewal key, in hex" ); 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()); } @@ -985,7 +1140,7 @@ mod tests { let _studio = client.with_subscriber(subscribe).connect(connect.clone()); let page = Url::parse("https://studio.example/join/").unwrap(); - let room = Arc::new(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. @@ -1030,7 +1185,7 @@ mod tests { } ); - let token = kio::clock::timeout(TIMEOUT, next_token(&renewals.consume(), "alice")) + let token = kio::clock::timeout(TIMEOUT, next_token(&renewals.consume(), &room, "alice")) .await .expect("the studio renews the guest") .expect("a token"); @@ -1041,34 +1196,73 @@ mod tests { .expect("the relay accepts the renewed token in band"); // Published before alice's, over the same session, so the relay holds it. + let bob = room.credential("bob").unwrap(); let other = kio::clock::timeout(TIMEOUT, async { - let broadcast = renewals.consume().request(format!("{RENEW}/bob"), None).await?; - read_token(broadcast).await + let broadcast = renewals.consume().request(Room::renewal_path(&bob)?, None).await?; + read_token(broadcast, &bob).await }) .await .expect("the relay answers"); assert!(other.is_err(), "alice read bob's token"); + + // A link signed before renewals existed lets its holder subscribe to the + // whole room, and so reach alice's renewals, but without alice's key + // it reads nothing. + let old = moq_room::claims("show/guests", "mallory") + .unwrap() + .with_expires(kio::clock::wall() + EXPIRY); + let mut mallory = settings.clone(); + mallory.tokens = vec![scoped.sign(&old).unwrap().try_into().unwrap()]; + mallory.version = vec![LITE_07.parse().unwrap()]; + let stolen = moq_tokio::origin::spawn(); + let _mallory = mallory + .init(Default::default()) + .unwrap() + .with_subscriber(stolen.clone()) + .connect(Url::parse(&fragment["url"]).unwrap()); + let alices = Room::renewal_path(&room.credential("alice").unwrap()).unwrap(); + let broadcast = kio::clock::timeout(TIMEOUT, async { + stolen.consume().follow(alices.clone(), None)?.available().await + }) + .await + .expect("the relay answers") + .expect("a room-wide grant reaches alice's renewals"); + let guess = credential("alice", [0; moq_e2ee::SECRET_LEN]).unwrap(); + let read = kio::clock::timeout(TIMEOUT, read_token(broadcast, &guess)).await; + assert!(!matches!(read, Ok(Ok(_))), "a room-wide grant read alice'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) -> anyhow::Result { - let broadcast = origin.follow(format!("{RENEW}/{identity}"), None)?.available().await?; - read_token(broadcast).await + async fn next_token(origin: &moq_net::origin::Consumer, room: &Room, identity: &str) -> anyhow::Result { + let credential = room.credential(identity)?; + let broadcast = origin + .follow(Room::renewal_path(&credential)?, None)? + .available() + .await?; + read_token(broadcast, &credential).await } - async fn read_token(broadcast: moq_net::broadcast::Consumer) -> anyhow::Result { - let mut track = broadcast.track(TOKEN_TRACK)?.subscribe(None).await?; + /// The newest token in `broadcast`, decrypted with `credential`. + async fn read_token( + broadcast: moq_net::broadcast::Consumer, + credential: &moq_e2ee::Credential, + ) -> anyhow::Result { + let epoch = broadcast.info().epoch.clone().context("no epoch")?; + let broadcast = moq_e2ee::broadcast::Consumer::new(broadcast, credential.consume(epoch)); + let mut track = broadcast.subscribe(TOKEN_TRACK, 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())?) + Ok(String::from_utf8(frame.plaintext.to_vec())?) } /// The tokens a renewed guest holds, as they read them: fresh while they are - /// in the room, each one `--guest-expiry` long, and none once the host + /// 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(); @@ -1085,44 +1279,83 @@ mod tests { join(true); renewals.sync(); - let first = key.verify(&next_token(&consumer, "alice").await.unwrap()).unwrap(); + let first = key + .verify(&next_token(&consumer, &room, "alice").await.unwrap()) + .unwrap(); assert_eq!(first.root, "show/guests"); assert_eq!(first.publish, ["alice/**".parse().unwrap()].into_iter().collect()); - assert_eq!( - first.subscribe, - [".renew/alice".parse().unwrap()].into_iter().collect(), - "a guest reads only its own renewals" - ); - assert_eq!( - first.expires.unwrap().saturating_duration_since(first.issued.unwrap()), - EXPIRY - ); + // 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").await.unwrap()).unwrap(); - assert_eq!( - second - .expires - .unwrap() - .saturating_duration_since(first.expires.unwrap()), - EXPIRY / 2 - ); + let second = key + .verify(&next_token(&consumer, &room, "alice").await.unwrap()) + .unwrap(); + assert!(ahead(&second) >= EXPIRY, "{:?}", ahead(&second)); - room.stop_renewing("alice"); + room.stop_renewing("alice").unwrap(); assert!(room.participants()["alice"].removed); renewals.sync(); assert!(renewals.tracks.is_empty(), "a removed guest's renewals end"); room.invite("alice").unwrap(); renewals.sync(); - next_token(&consumer, "alice").await.unwrap(); + next_token(&consumer, &room, "alice").await.unwrap(); join(false); renewals.sync(); assert!(renewals.tracks.is_empty(), "a guest who left is not renewed"); } + /// The studio publishes renewals where the join page looks for them: the + /// same vector as `renew.test.ts`. + #[test] + fn renewals_name_the_same_broadcast_as_the_join_page() { + let alice = credential("alice", [1; moq_e2ee::SECRET_LEN]).unwrap(); + assert_eq!( + Room::renewal_path(&alice).unwrap().as_str(), + ".renew/8W8-sbdtrkZZF_byr3vEcQ" + ); + } + + /// A removed guest stays unrenewed when the studio restarts, until a new + /// link, and their renewal key is the same across the restart. + #[test] + fn a_removal_outlasts_a_restart() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("room.jwk"); + key("show/guests", &["**"], &["**"]).to_file(&path).unwrap(); + let scenes = dir.path().join("show.toml"); + 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(), + }; + let start = || config.load(Some(&connect), &scenes).unwrap().unwrap(); + let camera = Path::new("alice/camera.hang").unwrap(); + + let room = start(); + let key = fragment(&room.invite("alice").unwrap().link)["key"].clone(); + room.stop_renewing("alice").unwrap(); + assert!(dir.path().join("show.guests.toml").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"); + assert_eq!(fragment(&room.invite("alice").unwrap().link)["key"], key); + 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"); + } + /// A guest joining is not the host's edit: what the host undid can still /// be redone, and undo never takes the guest out. A guest whose source the /// host removed comes back under a name of its own, so undoing the removal diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 93e2b97..3b27244 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -130,7 +130,10 @@ 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())?.map(Arc::new); + let room = config + .guests + .load(config.client.url.as_ref(), &config.scenes)? + .map(Arc::new); if let Some(room) = &room { let renewal = guests::Renewal::connect(room.clone(), &config.client, config.quic.clone())?; runtime.spawn(renewal.run()); diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index e1e240e..cf2e9ee 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -1333,12 +1333,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())); @@ -1349,7 +1354,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. Their token stops renewing, so they leave within --guest-expiry unless you send a new link.", + "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() { @@ -1371,7 +1376,10 @@ impl Panels { } } if let Some(name) = remove { - room.stop_renewing(&name); + 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) { From 2cf7ccef5d70f41b344cea5412a24eba71350d70 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 19:39:38 -0800 Subject: [PATCH 07/12] test(join): compare the renewal path vector as a path Co-Authored-By: Claude Opus 5.5 --- js/join/src/renew.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/join/src/renew.test.ts b/js/join/src/renew.test.ts index c22d3b8..19a1b9e 100644 --- a/js/join/src/renew.test.ts +++ b/js/join/src/renew.test.ts @@ -51,7 +51,7 @@ test("each token the studio publishes reaches the page, decrypted with its own k test("a key that is not the guest's names another broadcast", async () => { const right = await path(credential("alice", new Uint8Array(32).fill(1))); // The same vector as `renewals_name_the_same_broadcast_as_the_join_page` in the studio. - expect(right).toBe(".renew/8W8-sbdtrkZZF_byr3vEcQ"); + expect(right).toBe(Net.Path.from(".renew/8W8-sbdtrkZZF_byr3vEcQ")); expect(await path(credential("alice", new Uint8Array(32).fill(1)))).toBe(right); expect(await path(credential("alice", new Uint8Array(32).fill(2)))).not.toBe(right); expect(await path(credential("bob", new Uint8Array(32).fill(1)))).not.toBe(right); From 253754dd70e6139e6463eff1b029eb9b5c12dcf2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 19:45:16 -0800 Subject: [PATCH 08/12] fix(studio): make a removal only once its file holds it Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/guests.rs | 42 ++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs index b7b09d9..46d7cbd 100644 --- a/rs/moq-studio/src/guests.rs +++ b/rs/moq-studio/src/guests.rs @@ -271,6 +271,23 @@ impl Room { Ok(self) } + /// Mark `identity` removed or not, once the file holds it, so a failed + /// write leaves both as they were. + fn set_removed( + &self, + participants: &mut BTreeMap, + identity: &str, + removed: bool, + ) -> anyhow::Result<()> { + let participant = participants.entry(identity.to_string()).or_default(); + let before = std::mem::replace(&mut participant.removed, removed); + let saved = self.save_removals(participants); + if saved.is_err() { + participants.entry(identity.to_string()).or_default().removed = 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(()) }; @@ -324,12 +341,11 @@ impl Room { link.set_fragment(Some(&fragment)); let invite = Invite { link, expires }; let mut participants = self.participants.lock().unwrap(); - let participant = participants.entry(name.to_string()).or_default(); - participant.invite = Some(invite.clone()); // A new link lets a removed guest back in. - if std::mem::take(&mut participant.removed) { - self.save_removals(&participants)?; + if participants.get(name).is_some_and(|participant| participant.removed) { + self.set_removed(&mut participants, name, false)?; } + participants.entry(name.to_string()).or_default().invite = Some(invite.clone()); drop(participants); self.changed.notify_one(); Ok(invite) @@ -339,9 +355,11 @@ impl Room { /// until the host makes them a new link. pub fn stop_renewing(&self, identity: &str) -> anyhow::Result<()> { let mut participants = self.participants.lock().unwrap(); - let participant = participants.entry(identity.to_string()).or_default(); - if !std::mem::replace(&mut participant.removed, true) { - self.save_removals(&participants)?; + if !participants + .get(identity) + .is_some_and(|participant| participant.removed) + { + self.set_removed(&mut participants, identity, true)?; } drop(participants); self.changed.notify_one(); @@ -1354,6 +1372,16 @@ mod tests { let room = start(); room.observe(&camera, true, &Collection::new(), &History::new()); assert_eq!(room.renewed(), ["alice"], "a new link is remembered too"); + + // A removal the file cannot hold is not made, so trying again saves it. + let file = dir.path().join("show.guests.toml"); + 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 From 39693b417e6ca12cfb064d1ce09fd9beebb06785 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 19:58:34 -0800 Subject: [PATCH 09/12] refactor(studio): rotate the room key instead of encrypting renewals The maintainer picked documenting a room key rotation over moq-e2ee for links issued before renewal, whose claims subscribe to the whole room. Drop moq-e2ee, `@moq/e2ee`, and the link's `key=`: renewals are published in the clear at `.renew/`, which only that guest's new token may subscribe to. The README tells operators upgrading to rotate the room key, and to grant subscribe in the room only to the studio. Persisted removals, the one-minute --guest-expiry minimum with rounded-up expiries, and the Guests panel fixes stay. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 258 ------------------------------------ Cargo.toml | 2 - README.md | 12 +- bun.lock | 16 --- js/join/package.json | 1 - js/join/src/index.ts | 2 +- js/join/src/link.test.ts | 17 +-- js/join/src/link.ts | 15 +-- js/join/src/renew.test.ts | 45 ++----- js/join/src/renew.ts | 66 +++------ package.json | 1 - rs/moq-studio/Cargo.toml | 4 - rs/moq-studio/src/guests.rs | 182 +++++-------------------- 13 files changed, 85 insertions(+), 536 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f30f089..6ead147 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,8 +482,6 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", - "log", - "prettyplease", "proc-macro2", "quote", "regex", @@ -914,17 +912,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-models" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3344d349528f449816cfa85e558c439bdca5a6d8a738cfb21e69f0b2b1952f" -dependencies = [ - "hax-lib 0.3.7", - "pastey", - "rand", -] - [[package]] name = "core_affinity" version = "0.8.3" @@ -996,18 +983,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crabgrind" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "035c801dc4b418e1d857a6536dc88b4d1929c7a1fd163389766e18d322d34a84" -dependencies = [ - "bindgen 0.72.1", - "cc", - "pkg-config", - "rustversion", -] - [[package]] name = "crc" version = "3.4.0" @@ -1114,31 +1089,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "fiat-crypto", - "rustc_version", - "subtle", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "darling" version = "0.24.1" @@ -2304,79 +2254,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "hax-lib" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a33cb227f97ee5c419064c31d7c55a7d895f28d8019ccdadc311cc036500c4" -dependencies = [ - "hax-lib-macros 0.3.7", - "num-bigint", - "num-traits", -] - -[[package]] -name = "hax-lib" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3bfcfc915e894c331662e4ac76706b777a5956535ea24535f4df30da04b1bf" -dependencies = [ - "hax-lib-macros 0.4.2", - "num-bigint", - "num-traits", -] - -[[package]] -name = "hax-lib-macros" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28da835a5f153e9f3d0cc1f42ee605a1cfa9093c93348193793d60a1b0908b51" -dependencies = [ - "hax-lib-macros-types 0.3.7", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "hax-lib-macros" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9010990fcba1d1494047fa433a433be0a5df23653a9784c739f7bba270272e" -dependencies = [ - "hax-lib-macros-types 0.4.2", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "hax-lib-macros-types" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed327e9a28d6ee018a4a9ba842d4ee27e378c8a591d2beb6f42f32b24a02fcb" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "hax-lib-macros-types" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e053e3a5aeb92df47b07323180c5088edc488ca49883a234f6c73255c54cc520" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_json", - "uuid", -] - [[package]] name = "heck" version = "0.5.0" @@ -3060,86 +2937,6 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" -[[package]] -name = "libcrux-aes" -version = "0.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd415025511a0bd8731cae34ae2d5e07c1b3f0443d5c12bd199c9e9b2f1fd4d5" -dependencies = [ - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-secrets", - "libcrux-traits", -] - -[[package]] -name = "libcrux-hacl-rs" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ce64ead37fc67c1039fdbcf1e8c97044e6a644cb3d1f79060b5c9ac8fdfe0a" -dependencies = [ - "libcrux-macros", -] - -[[package]] -name = "libcrux-intrinsics" -version = "0.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de82722c9c7419a311e84ee902e9643a15e39a6fcaade87f21e94f8d0f30fa70" -dependencies = [ - "core-models", - "hax-lib 0.4.2", -] - -[[package]] -name = "libcrux-macros" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2afdd4da30d6e23f76bd6f3ad61be5c773720349eef3e791f8b79cfea3fe1436" -dependencies = [ - "quote", - "syn 3.0.6", -] - -[[package]] -name = "libcrux-platform" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf8bee8c35d34867a3762d6ee7b74e8523a882855c3cfce30484e9554342206" -dependencies = [ - "libc", -] - -[[package]] -name = "libcrux-poly1305" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d727ee28ee0d5d2661daa5f2f47965cd3489a0ac9130f3410e95632a98b12e49" -dependencies = [ - "libcrux-hacl-rs", - "libcrux-macros", -] - -[[package]] -name = "libcrux-secrets" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c4ba47b3596f2771213ff162c792e9b93ddc0cdd178a2e407e00bb8ae4afcd" -dependencies = [ - "crabgrind", - "hax-lib 0.4.2", -] - -[[package]] -name = "libcrux-traits" -version = "0.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8958bb5c3780eb7c3355aeeb051629b830e47bfefd36befed93c81b53366bbc4" -dependencies = [ - "libcrux-secrets", - "rand", -] - [[package]] name = "libloading" version = "0.8.9" @@ -3431,19 +3228,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "moq-e2ee" -version = "0.0.11" -dependencies = [ - "base64 0.23.1", - "bytes", - "kio", - "moq-net", - "rind", - "thiserror 2.0.21", - "zeroize", -] - [[package]] name = "moq-json" version = "0.5.8" @@ -3691,7 +3475,6 @@ dependencies = [ "kio", "moq-audio", "moq-auth", - "moq-e2ee", "moq-json", "moq-mux", "moq-net", @@ -3704,7 +3487,6 @@ dependencies = [ "mp4-atom", "pollster", "qrcodegen", - "rind", "serde", "tempfile", "tokio", @@ -5084,28 +4866,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -5526,24 +5286,6 @@ dependencies = [ "hmac", ] -[[package]] -name = "rind" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "422500e50252ad3bd319f359c54431441711a8fd55cb228973dde123ff8c2039" -dependencies = [ - "cfg-if", - "curve25519-dalek", - "fiat-crypto", - "getrandom 0.4.3", - "libc", - "libcrux-aes", - "libcrux-hacl-rs", - "libcrux-macros", - "libcrux-poly1305", - "libcrux-traits", -] - [[package]] name = "ring" version = "0.17.14" diff --git a/Cargo.toml b/Cargo.toml index 942cf66..b4cb309 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ allow_attributes_without_reason = "warn" hang = { path = "moq/rs/hang" } kio = { path = "moq/rs/kio" } moq-auth = { path = "moq/rs/moq-auth", default-features = false } -moq-e2ee = { path = "moq/rs/moq-e2ee" } moq-audio = { path = "moq/rs/moq-audio", default-features = false } moq-json = { path = "moq/rs/moq-json" } moq-mux = { path = "moq/rs/moq-mux" } @@ -44,7 +43,6 @@ anyhow = "1" bytes = "1" humantime-serde = "1.1.1" pollster = "1.0" -rind = "0.1.2" serde = { version = "1", features = ["derive"] } serde_with = "3" tempfile = "3" diff --git a/README.md b/README.md index 0f448a1..3ca3de9 100644 --- a/README.md +++ b/README.md @@ -172,11 +172,15 @@ moq-studio show.toml --connect 'https://relay.example.com/show?jwt=...' \ 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. Each renewal is encrypted to a key only that guest's - links carry, so nothing else that can subscribe in the room, a link from an - older studio included, can read it. A guest who leaves needs a link, or a - renewed token, that has not expired to come back. The Guests panel says + 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 and + drop the old one's public half from `--key-dir`. Links from before let + their holder subscribe to the whole room, renewals included, so they must + die with the old key. - 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 diff --git a/bun.lock b/bun.lock index e63d424..35032ec 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "name": "@moq/studio-join", "version": "0.0.0", "dependencies": { - "@moq/e2ee": "workspace:*", "@moq/net": "workspace:*", "@moq/publish": "workspace:*", "@moq/room": "workspace:*", @@ -34,19 +33,6 @@ "typescript": "7.0.2", }, }, - "moq/js/e2ee": { - "name": "@moq/e2ee", - "version": "0.0.0", - "dependencies": { - "@moq/net": "workspace:^", - }, - "devDependencies": { - "@moq/clock": "workspace:^", - "@types/bun": "^1.4.4", - "rimraf": "^6.1.3", - "typescript": "7.0.2", - }, - }, "moq/js/hang": { "name": "@moq/hang", "version": "0.5.2", @@ -334,8 +320,6 @@ "@moq/clock": ["@moq/clock@workspace:moq/js/clock"], - "@moq/e2ee": ["@moq/e2ee@workspace:moq/js/e2ee"], - "@moq/hang": ["@moq/hang@workspace:moq/js/hang"], "@moq/json": ["@moq/json@workspace:moq/js/json"], diff --git a/js/join/package.json b/js/join/package.json index 2993722..a08eb16 100644 --- a/js/join/package.json +++ b/js/join/package.json @@ -13,7 +13,6 @@ "test": "bun test" }, "dependencies": { - "@moq/e2ee": "workspace:*", "@moq/net": "workspace:*", "@moq/publish": "workspace:*", "@moq/room": "workspace:*", diff --git a/js/join/src/index.ts b/js/join/src/index.ts index 3762cfe..e87033e 100644 --- a/js/join/src/index.ts +++ b/js/join/src/index.ts @@ -59,7 +59,7 @@ function join(link: Link): () => void { 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.credential(link.name, link.key), (jwt) => { + const renewal = Renew.follow(connection.origin, link.name, (jwt) => { tokens.set([jwt]); // So joining again, or a reload, uses the newest token once the link's has expired. link.jwt = jwt; diff --git a/js/join/src/link.test.ts b/js/join/src/link.test.ts index 7ab8f99..85dd12a 100644 --- a/js/join/src/link.test.ts +++ b/js/join/src/link.test.ts @@ -3,40 +3,31 @@ import { format, parse } from "./link.ts"; const fragment = (params: Record) => new URLSearchParams(params).toString(); -const KEY = "00ff".repeat(16); - 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", key: KEY }), - ); + const link = parse(fragment({ url: "https://relay.example:4443/show/guests", name: "alice", jwt: "a.b.c" })); expect(link.name).toBe("alice"); expect(link.url.origin).toBe("https://relay.example:4443"); expect(link.url.pathname).toBe("/show/guests"); expect(link.url.search).toBe(""); expect(link.jwt).toBe("a.b.c"); - expect(Array.from(link.key.slice(0, 2))).toEqual([0, 255]); - expect(link.key.length).toBe(32); // 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", key: KEY })); + const link = parse(fragment({ url: "https://relay.example/room", name: "Alice Smith", jwt: "t" })); 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", key: KEY }; - for (const key of ["url", "name", "jwt", "key"] as const) { + const valid = { url: "https://relay.example/room", name: "alice", jwt: "t" }; + for (const key of ["url", "name", "jwt"] 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 key of ["00", KEY.toUpperCase(), `${KEY}00`, "zz".repeat(32)]) { - expect(() => parse(fragment({ ...valid, key }))).toThrow("key is invalid"); - } 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 287c0c2..43f6cc2 100644 --- a/js/join/src/link.ts +++ b/js/join/src/link.ts @@ -14,8 +14,6 @@ export interface Link { name: string; /** The token the studio signed for the guest: the link's, or a renewed one. */ jwt: string; - /** The 32-byte key that decrypts the guest's renewed tokens. */ - key: Uint8Array; } /** Parse a join link's fragment, without its `#`. Throws on a link the studio did not make. */ @@ -24,8 +22,7 @@ export function parse(fragment: string): Link { const relay = params.get("url"); const name = params.get("name"); const jwt = params.get("jwt"); - const hex = params.get("key"); - if (!relay || !name || !jwt || !hex) throw new Error("this join link is incomplete; ask the host for a new one"); + if (!relay || !name || !jwt) throw new Error("this join link is incomplete; ask the host for a new one"); let url: URL; try { @@ -40,16 +37,10 @@ export function parse(fragment: string): Link { throw new Error(`this join link names an invalid guest: ${name}`); } - const key = hex.match(/^[0-9a-f]{64}$/) - ? Uint8Array.from(hex.match(/../g) ?? [], (byte) => parseInt(byte, 16)) - : undefined; - if (!key) throw new Error("this join link's key is invalid; ask the host for a new one"); - - return { url, name, jwt, key }; + return { url, name, jwt }; } /** The fragment {@link parse} reads `link` back from. */ export function format(link: Link): string { - const key = Array.from(link.key, (byte) => byte.toString(16).padStart(2, "0")).join(""); - return new URLSearchParams({ url: link.url.href, name: link.name, jwt: link.jwt, key }).toString(); + return new URLSearchParams({ url: link.url.href, name: link.name, jwt: link.jwt }).toString(); } diff --git a/js/join/src/renew.test.ts b/js/join/src/renew.test.ts index 19a1b9e..033e5cd 100644 --- a/js/join/src/renew.test.ts +++ b/js/join/src/renew.test.ts @@ -1,8 +1,7 @@ import { expect, test } from "bun:test"; -import * as E2ee from "@moq/e2ee"; import * as Net from "@moq/net"; import { Signal } from "@moq/signals"; -import { credential, follow, path } from "./renew.ts"; +import { follow, path } from "./renew.ts"; const encode = (text: string) => new TextEncoder().encode(text); @@ -15,44 +14,26 @@ async function received(tokens: Signal, count: number): Promise { - const group = track.appendGroup(); - await group.writeFrame(undefined, encode(token)); - group.finish(); - }; -} - -test("each token the studio publishes reaches the page, decrypted with its own key", async () => { +test("each token the studio publishes reaches the page, and only for its own name", async () => { const origin = new Net.Origin.Producer(); - const alice = new Uint8Array(32).fill(1); const tokens = new Signal([]); - const renewal = follow(new Signal(origin), credential("alice", alice), (token) => + const renewal = follow(new Signal(origin), "alice", (token) => tokens.update((all) => [...all, token]), ); - await (await publish(origin, "bob", new Uint8Array(32).fill(2)))("bob's"); - const write = await publish(origin, "alice", alice); - await write("first"); + const other = origin.createBroadcast(path("bob")); + other.broadcast.createTrack("token").writeFrame({ payload: encode("bob's") }); + other.announce(); + + const publication = origin.createBroadcast(path("alice")); + const track = publication.broadcast.createTrack("token"); + track.writeFrame({ payload: encode("first") }); + publication.announce(); expect(await received(tokens, 1)).toEqual(["first"]); - await write("second"); + + track.writeFrame({ payload: encode("second") }); expect(await received(tokens, 2)).toEqual(["first", "second"]); renewal.close(); origin.close(); }); - -test("a key that is not the guest's names another broadcast", async () => { - const right = await path(credential("alice", new Uint8Array(32).fill(1))); - // The same vector as `renewals_name_the_same_broadcast_as_the_join_page` in the studio. - expect(right).toBe(Net.Path.from(".renew/8W8-sbdtrkZZF_byr3vEcQ")); - expect(await path(credential("alice", new Uint8Array(32).fill(1)))).toBe(right); - expect(await path(credential("alice", new Uint8Array(32).fill(2)))).not.toBe(right); - expect(await path(credential("bob", new Uint8Array(32).fill(1)))).not.toBe(right); -}); diff --git a/js/join/src/renew.ts b/js/join/src/renew.ts index d0a1dfc..b4bbfde 100644 --- a/js/join/src/renew.ts +++ b/js/join/src/renew.ts @@ -1,75 +1,53 @@ /** * Renewal: while a guest is in the room, the studio publishes a fresh token for - * them every half link lifetime, encrypted with `@moq/e2ee` to the key in their - * link, 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. + * 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 E2ee from "@moq/e2ee"; import * as Net from "@moq/net"; -import { Effect, type Getter, Signal } from "@moq/signals"; +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"; -/** The prefix of every guest's renewals: `RENEW` in `rs/moq-studio/src/guests.rs`. */ -const PREFIX = ".renew"; - -/** The semantic broadcast and track names: `RENEW_BROADCAST` and `TOKEN_TRACK` there. */ -const BROADCAST = "renew"; -const TRACK = "token"; - -/** The credential that decrypts `name`'s renewals, from the 32-byte `key` in their link. */ -export function credential(name: string, key: Uint8Array): E2ee.Credential { - return new E2ee.Credential({ context: new TextEncoder().encode(name), kid: 0, secret: key }); +/** Where the studio publishes `name`'s tokens: `RENEW` in `rs/moq-studio/src/guests.rs`. */ +export function path(name: string): Net.Path.Valid { + return Net.Path.from(`.renew/${name}`); } -/** Where the renewals `credential` decrypts are published. */ -export async function path(credential: E2ee.Credential): Promise { - return Net.Path.join(Net.Path.from(PREFIX), await credential.path(BROADCAST)); -} +/** 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 with `credential` in `origin`, - * across the studio's restarts, until the returned effect is closed. + * Hand `renewed` each token the studio publishes for `name` in `origin`, across + * the studio's restarts, until the returned effect is closed. */ export function follow( origin: Getter, - credential: E2ee.Credential, + name: string, renewed: (token: string) => void, ): Effect { const signals = new Effect(); - const at = new Signal(undefined); - signals.spawn(async () => at.set(await path(credential))); - signals.run((effect) => { const reader = effect.get(origin); - const renewals = effect.get(at); - if (!reader || !renewals) return; - const request = reader.follow(renewals); + if (!reader) return; + const request = reader.follow(path(name)); effect.cleanup(() => request.close()); effect.run((effect) => { const broadcast = effect.get(request.active); - // moq-lite-07 carries the publisher's epoch, which keys its encryption. - const epoch = broadcast?.epoch; - if (!broadcast || !epoch) return; - const decrypted = new E2ee.Broadcast.Consumer(broadcast, credential.consume(epoch)); + if (!broadcast) return; + const track = broadcast.track(TRACK).subscribe(); + effect.cleanup(() => track.close()); effect.spawn(async () => { - const track = await effect.race(decrypted.subscribe(TRACK)); - if (!track) return; - try { - for (;;) { - const group = await effect.race(track.recvGroup()); - if (!group) return; - const frame = await effect.race(group.readFrame()); - if (frame) renewed(new TextDecoder().decode(frame.plaintext)); - } - } finally { - track.close(); + for (;;) { + const group = await effect.race(track.recvGroup()); + if (!group) return; + const token = await effect.race(group.readString()); + if (token) renewed(token); } }); }); diff --git a/package.json b/package.json index 6affc63..507a467 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "workspaces": [ "js/join", "moq/js/clock", - "moq/js/e2ee", "moq/js/hang", "moq/js/json", "moq/js/libav", diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 49e22e0..65ef2ab 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -47,8 +47,6 @@ kio = { workspace = true, features = ["async", "runtime"] } moq-audio = { workspace = true, features = ["bus"] } # Signs guest tokens with the room key. moq-auth = { workspace = true } -# Encrypts each guest's renewed tokens to the key in their link. -moq-e2ee = { workspace = true } moq-json = { workspace = true } moq-mux = { workspace = true } moq-net = { workspace = true } @@ -61,8 +59,6 @@ moq-video = { workspace = true } pollster = { workspace = true } # The join link's QR code. No dependencies of its own. qrcodegen = "1.8" -# Derives each guest's renewal key from the room key, as moq-e2ee does its own. -rind = { workspace = true } # The guests removed from scenes, kept beside the scene file. serde = { workspace = true } tempfile = { workspace = true } diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs index 46d7cbd..aa22f38 100644 --- a/rs/moq-studio/src/guests.rs +++ b/rs/moq-studio/src/guests.rs @@ -12,9 +12,8 @@ //! //! 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 under `.renew/`, encrypted with moq-e2ee to -//! a key only their link carries, at a path only their token may subscribe to. -//! The join page presents each one in band. Removing a guest from scenes stops +//! `--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, so their last token //! bounds how long they stay. @@ -34,13 +33,12 @@ 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 renewals, which moq-room's announcements hide. -/// Beneath it, moq-e2ee names the broadcast after the guest's key. +/// Where each guest's renewed tokens are published: `.renew/`, which +/// moq-room's announcements hide. `js/join` reads the same path. const RENEW: &str = ".renew"; -/// The semantic broadcast and track names of a guest's renewals, one token per -/// group. `js/join` reads the same names. -const RENEW_BROADCAST: &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 @@ -97,16 +95,6 @@ impl Config { } } -/// The moq-e2ee credential of `identity`'s renewals under `secret`, as -/// `credential` in `js/join/src/renew.ts` builds it. -fn credential(identity: &str, secret: [u8; moq_e2ee::SECRET_LEN]) -> anyhow::Result { - Ok(moq_e2ee::Credential::new(moq_e2ee::credential::Config { - context: bytes::Bytes::copy_from_slice(identity.as_bytes()), - kid: 0, - secret, - })?) -} - /// Where the guests removed from scenes are kept: `show.guests.toml` for /// `show.toml`, beside its history. fn removals_path(scenes: &std::path::Path) -> PathBuf { @@ -132,8 +120,6 @@ pub struct Room { /// Where a guest dials: the relay at the room, without the studio's credentials. url: Url, key: moq_auth::Key, - /// Derives each guest's renewal key, from the room key so it survives a restart. - renewals: rind::hmac::Key, page: Url, expiry: Duration, participants: Mutex>, @@ -224,23 +210,11 @@ impl Room { ); check_scope(&key, &root)?; - // The private key, which only the studio holds, so no one else can derive - // a guest's renewal key. - let secret = match &key.material { - moq_auth::KeyMaterial::EC { d: Some(d), .. } | moq_auth::KeyMaterial::OKP { d: Some(d), .. } => d, - moq_auth::KeyMaterial::RSA { - private: Some(private), .. - } => &private.d, - moq_auth::KeyMaterial::OCT { secret } => secret, - _ => anyhow::bail!("the room key has no private half, so it cannot sign guests' tokens"), - }; - let renewals = rind::hmac::Key::new(&rind::hmac::HMAC_SHA256, secret); let room = Self { path, root, url, key, - renewals, page, expiry, participants: Mutex::default(), @@ -330,13 +304,11 @@ impl Room { // 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 secret: String = self.secret(name).iter().map(|byte| format!("{byte:02x}")).collect(); 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("key", &secret) .finish(); link.set_fragment(Some(&fragment)); let invite = Invite { link, expires }; @@ -366,23 +338,9 @@ impl Room { Ok(()) } - /// The key that encrypts `identity`'s renewals, which only their links carry. - fn secret(&self, identity: &str) -> [u8; moq_e2ee::SECRET_LEN] { - let tag = rind::hmac::sign(&self.renewals, identity.as_bytes()); - tag.as_ref() - .try_into() - .expect("HMAC-SHA256 is as long as a moq-e2ee secret") - } - - /// The moq-e2ee credential of `identity`'s renewals. - fn credential(&self, identity: &str) -> anyhow::Result { - credential(identity, self.secret(identity)) - } - - /// Where `identity`'s renewals are published, in the room: a name only their - /// key derives. - fn renewal_path(credential: &moq_e2ee::Credential) -> anyhow::Result { - Ok(Path::new(RENEW)?.join(&credential.path(RENEW_BROADCAST)?)?) + /// Where `identity`'s renewals are published, in the room. + fn renewal_path(identity: &str) -> anyhow::Result { + Ok(Path::new(&format!("{RENEW}/{identity}"))?) } /// Why the session publishing guests' renewed tokens is not connected, if it @@ -397,7 +355,7 @@ impl Room { /// another's. fn sign(&self, identity: &str) -> anyhow::Result<(String, kio::clock::Wall)> { let invalid = || format!("cannot invite `{identity}`"); - let renewals = Self::renewal_path(&self.credential(identity).with_context(invalid)?).with_context(invalid)?; + let renewals = Self::renewal_path(identity).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())?]); @@ -705,17 +663,15 @@ struct Renewals { tracks: BTreeMap, } -/// One guest's token track, encrypted to their key, ended when dropped. +/// One guest's token track, ended when dropped. struct Renewed { publication: moq_net::origin::Publication, - track: Option, + track: moq_net::track::Producer, } impl Drop for Renewed { fn drop(&mut self) { - if let Some(track) = self.track.take() { - let _ = track.finish(); - } + let _ = self.track.finish(); self.publication.close(); } } @@ -749,18 +705,11 @@ impl Renewals { /// The token track for `identity`, holding a fresh token. fn publish(&self, identity: &str) -> anyhow::Result { - let credential = self.room.credential(identity)?; - let generation = credential.produce(); - let publication = self.origin.create_broadcast(Room::renewal_path(&credential)?)?; - let protected = moq_e2ee::broadcast::Producer::new(publication.broadcast().clone(), generation.clone()); - let track = protected.create_track(TOKEN_TRACK, None)?; - let mut renewed = Renewed { - publication, - track: Some(track), - }; + let publication = self.origin.create_broadcast(Room::renewal_path(identity)?)?; + let track = publication.create_track(TOKEN_TRACK, None)?; + let mut renewed = Renewed { publication, track }; self.write(identity, &mut renewed)?; - let route = moq_net::origin::Route::default().with_epoch(generation.epoch().clone()); - renewed.publication.announce(route)?; + renewed.publication.announce(Default::default())?; Ok(renewed) } @@ -777,10 +726,7 @@ impl Renewals { fn write(&self, identity: &str, renewed: &mut Renewed) -> anyhow::Result<()> { let (token, _) = self.room.sign(identity)?; - let track = renewed.track.as_mut().expect("held until dropped"); - let mut group = track.append_group()?; - group.write_frame(None, token.as_bytes())?; - group.finish()?; + renewed.track.write_frame(None, token)?; Ok(()) } } @@ -997,19 +943,11 @@ mod tests { let claims = key.verify(&fragment["jwt"]).unwrap(); assert_eq!(claims.root, "show/guests"); assert_eq!(claims.publish, ["alice/**".parse().unwrap()].into_iter().collect()); - let renewals = Room::renewal_path(&room.credential("alice").unwrap()).unwrap(); assert_eq!( claims.subscribe, - [moq_auth::Pattern::literal(renewals.as_str()).unwrap()] - .into_iter() - .collect(), + [".renew/alice".parse().unwrap()].into_iter().collect(), "a guest reads only its own renewals" ); - assert_eq!( - fragment["key"].len(), - moq_e2ee::SECRET_LEN * 2, - "the renewal key, in hex" - ); let (issued, expires) = (claims.issued.unwrap(), claims.expires.unwrap()); let lifetime = expires.saturating_duration_since(issued); assert!( @@ -1203,7 +1141,7 @@ mod tests { } ); - let token = kio::clock::timeout(TIMEOUT, next_token(&renewals.consume(), &room, "alice")) + let token = kio::clock::timeout(TIMEOUT, next_token(&renewals.consume(), "alice")) .await .expect("the studio renews the guest") .expect("a token"); @@ -1214,63 +1152,26 @@ mod tests { .expect("the relay accepts the renewed token in band"); // Published before alice's, over the same session, so the relay holds it. - let bob = room.credential("bob").unwrap(); let other = kio::clock::timeout(TIMEOUT, async { - let broadcast = renewals.consume().request(Room::renewal_path(&bob)?, None).await?; - read_token(broadcast, &bob).await + let broadcast = renewals.consume().request(Room::renewal_path("bob")?, None).await?; + read_token(broadcast).await }) .await .expect("the relay answers"); assert!(other.is_err(), "alice read bob's token"); - - // A link signed before renewals existed lets its holder subscribe to the - // whole room, and so reach alice's renewals, but without alice's key - // it reads nothing. - let old = moq_room::claims("show/guests", "mallory") - .unwrap() - .with_expires(kio::clock::wall() + EXPIRY); - let mut mallory = settings.clone(); - mallory.tokens = vec![scoped.sign(&old).unwrap().try_into().unwrap()]; - mallory.version = vec![LITE_07.parse().unwrap()]; - let stolen = moq_tokio::origin::spawn(); - let _mallory = mallory - .init(Default::default()) - .unwrap() - .with_subscriber(stolen.clone()) - .connect(Url::parse(&fragment["url"]).unwrap()); - let alices = Room::renewal_path(&room.credential("alice").unwrap()).unwrap(); - let broadcast = kio::clock::timeout(TIMEOUT, async { - stolen.consume().follow(alices.clone(), None)?.available().await - }) - .await - .expect("the relay answers") - .expect("a room-wide grant reaches alice's renewals"); - let guess = credential("alice", [0; moq_e2ee::SECRET_LEN]).unwrap(); - let read = kio::clock::timeout(TIMEOUT, read_token(broadcast, &guess)).await; - assert!(!matches!(read, Ok(Ok(_))), "a room-wide grant read alice's token"); } /// The newest token published for `identity` in `origin`, once there is one. - async fn next_token(origin: &moq_net::origin::Consumer, room: &Room, identity: &str) -> anyhow::Result { - let credential = room.credential(identity)?; - let broadcast = origin - .follow(Room::renewal_path(&credential)?, None)? - .available() - .await?; - read_token(broadcast, &credential).await + async fn next_token(origin: &moq_net::origin::Consumer, identity: &str) -> anyhow::Result { + let broadcast = origin.follow(Room::renewal_path(identity)?, None)?.available().await?; + read_token(broadcast).await } - /// The newest token in `broadcast`, decrypted with `credential`. - async fn read_token( - broadcast: moq_net::broadcast::Consumer, - credential: &moq_e2ee::Credential, - ) -> anyhow::Result { - let epoch = broadcast.info().epoch.clone().context("no epoch")?; - let broadcast = moq_e2ee::broadcast::Consumer::new(broadcast, credential.consume(epoch)); - let mut track = broadcast.subscribe(TOKEN_TRACK, None)?.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.plaintext.to_vec())?) + Ok(String::from_utf8(frame.payload.to_vec())?) } /// The tokens a renewed guest holds, as they read them: fresh while they are @@ -1297,9 +1198,7 @@ mod tests { join(true); renewals.sync(); - let first = key - .verify(&next_token(&consumer, &room, "alice").await.unwrap()) - .unwrap(); + let first = key.verify(&next_token(&consumer, "alice").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. @@ -1309,9 +1208,7 @@ mod tests { clock.advance(EXPIRY / 2); assert!(ahead(&first) >= EXPIRY / 2, "renewed before the token expires"); renewals.renew(); - let second = key - .verify(&next_token(&consumer, &room, "alice").await.unwrap()) - .unwrap(); + let second = key.verify(&next_token(&consumer, "alice").await.unwrap()).unwrap(); assert!(ahead(&second) >= EXPIRY, "{:?}", ahead(&second)); room.stop_renewing("alice").unwrap(); @@ -1321,26 +1218,15 @@ mod tests { room.invite("alice").unwrap(); renewals.sync(); - next_token(&consumer, &room, "alice").await.unwrap(); + next_token(&consumer, "alice").await.unwrap(); join(false); renewals.sync(); assert!(renewals.tracks.is_empty(), "a guest who left is not renewed"); } - /// The studio publishes renewals where the join page looks for them: the - /// same vector as `renew.test.ts`. - #[test] - fn renewals_name_the_same_broadcast_as_the_join_page() { - let alice = credential("alice", [1; moq_e2ee::SECRET_LEN]).unwrap(); - assert_eq!( - Room::renewal_path(&alice).unwrap().as_str(), - ".renew/8W8-sbdtrkZZF_byr3vEcQ" - ); - } - /// A removed guest stays unrenewed when the studio restarts, until a new - /// link, and their renewal key is the same across the restart. + /// link. #[test] fn a_removal_outlasts_a_restart() { let dir = tempfile::tempdir().unwrap(); @@ -1358,7 +1244,7 @@ mod tests { let camera = Path::new("alice/camera.hang").unwrap(); let room = start(); - let key = fragment(&room.invite("alice").unwrap().link)["key"].clone(); + room.invite("alice").unwrap(); room.stop_renewing("alice").unwrap(); assert!(dir.path().join("show.guests.toml").exists()); @@ -1366,7 +1252,7 @@ mod tests { 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"); - assert_eq!(fragment(&room.invite("alice").unwrap().link)["key"], key); + room.invite("alice").unwrap(); assert_eq!(room.renewed(), ["alice"]); let room = start(); From 7c674f307a76174c2312e391e8aac6c28a1679f7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 22:25:32 -0800 Subject: [PATCH 10/12] fix(studio): a new link after a removal moves the guest's renewals on Address the review of 03800b7: - New link after Remove from scenes reused `.renew/`, which an earlier token for that name could still read. Renewals now live at `.renew//`; letting a removed guest back in bumps the generation, kept in `show.guests.toml`, and the link carries it. - The join page skips a renewal group superseded before it completed (`Old`) rather than ending its only reader. - The README's upgrade steps end sessions the relay already admitted with an old link (restart the relay, or wait out their expiry) before the upgraded studio starts. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 +++-- js/join/src/index.ts | 2 +- js/join/src/link.test.ts | 14 +++- js/join/src/link.ts | 15 +++- js/join/src/renew.test.ts | 43 +++++++++-- js/join/src/renew.ts | 24 ++++-- rs/moq-studio/src/guests.rs | 142 +++++++++++++++++++++++++----------- 7 files changed, 186 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index fa91ea5..bf06fd7 100644 --- a/README.md +++ b/README.md @@ -185,14 +185,16 @@ moq-studio show.toml --connect 'https://relay.example.com/show?jwt=...' \ 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 + `.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 and - drop the old one's public half from `--key-dir`. Links from before let - their holder subscribe to the whole room, renewals included, so they must - die with the old key. + 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 @@ -222,7 +224,8 @@ 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: removed guests are kept beside the scene file, as `show.guests.toml` for `show.toml`. Undo puts them back in the scenes but not -on renewal; **New link** lets them back in and renews them again. +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 diff --git a/js/join/src/index.ts b/js/join/src/index.ts index e87033e..be3d6ba 100644 --- a/js/join/src/index.ts +++ b/js/join/src/index.ts @@ -59,7 +59,7 @@ function join(link: Link): () => void { const connection = new Net.Connection({ url: link.url, versions: [Renew.VERSION], tokens, enabled: true }); signals.cleanup(() => connection.close()); - const renewal = Renew.follow(connection.origin, link.name, (jwt) => { + 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; diff --git a/js/join/src/link.test.ts b/js/join/src/link.test.ts index 85dd12a..bc21e1f 100644 --- a/js/join/src/link.test.ts +++ b/js/join/src/link.test.ts @@ -4,30 +4,36 @@ import { format, parse } from "./link.ts"; const fragment = (params: Record) => new URLSearchParams(params).toString(); 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" })); + 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.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 43f6cc2..c2b4ce1 100644 --- a/js/join/src/link.ts +++ b/js/join/src/link.ts @@ -14,6 +14,8 @@ export interface Link { 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. */ @@ -22,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 { @@ -37,10 +41,15 @@ export function parse(fragment: string): Link { throw new Error(`this join link names an invalid guest: ${name}`); } - return { url, name, jwt }; + 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 { - return new URLSearchParams({ url: link.url.href, name: link.name, jwt: link.jwt }).toString(); + 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 index 033e5cd..b13be84 100644 --- a/js/join/src/renew.test.ts +++ b/js/join/src/renew.test.ts @@ -14,18 +14,23 @@ async function received(tokens: Signal, count: number): Promise { +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), "alice", (token) => + const renewal = follow(new Signal(origin), path("alice", 0), (token) => tokens.update((all) => [...all, token]), ); - const other = origin.createBroadcast(path("bob")); - other.broadcast.createTrack("token").writeFrame({ payload: encode("bob's") }); - other.announce(); + 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")); + const publication = origin.createBroadcast(path("alice", 0)); const track = publication.broadcast.createTrack("token"); track.writeFrame({ payload: encode("first") }); publication.announce(); @@ -37,3 +42,29 @@ test("each token the studio publishes reaches the page, and only for its own nam 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 index b4bbfde..3e1f921 100644 --- a/js/join/src/renew.ts +++ b/js/join/src/renew.ts @@ -13,28 +13,31 @@ 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: `RENEW` in `rs/moq-studio/src/guests.rs`. */ -export function path(name: string): Net.Path.Valid { - return Net.Path.from(`.renew/${name}`); +/** + * 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 for `name` in `origin`, across + * 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, - name: string, + 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(path(name)); + const request = reader.follow(renewals); effect.cleanup(() => request.close()); effect.run((effect) => { @@ -46,7 +49,14 @@ export function follow( for (;;) { const group = await effect.race(track.recvGroup()); if (!group) return; - const token = await effect.race(group.readString()); + 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); } }); diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs index aa22f38..2d75de0 100644 --- a/rs/moq-studio/src/guests.rs +++ b/rs/moq-studio/src/guests.rs @@ -95,8 +95,8 @@ impl Config { } } -/// Where the guests removed from scenes are kept: `show.guests.toml` for -/// `show.toml`, beside its history. +/// Where the guests removed from scenes, and how often each was let back in, +/// are kept: `show.guests.toml` for `show.toml`, beside its history. fn removals_path(scenes: &std::path::Path) -> PathBuf { let mut name = scenes.file_stem().unwrap_or_default().to_os_string(); name.push(".guests.toml"); @@ -108,7 +108,11 @@ fn removals_path(scenes: &std::path::Path) -> PathBuf { #[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. @@ -143,6 +147,9 @@ pub struct Participant { /// 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 { @@ -224,7 +231,7 @@ impl Room { }; // Sign once now, so a key that cannot sign for guests or for the studio's // own session fails at startup. - room.sign("guest")?; + room.sign("guest", 0)?; room.sign_session()?; Ok(room) } @@ -241,23 +248,27 @@ impl Room { 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) } - /// Mark `identity` removed or not, once the file holds it, so a failed - /// write leaves both as they were. - fn set_removed( + /// 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, - removed: bool, + change: impl FnOnce(&mut Participant), ) -> anyhow::Result<()> { let participant = participants.entry(identity.to_string()).or_default(); - let before = std::mem::replace(&mut participant.removed, removed); + let before = participant.clone(); + change(participant); let saved = self.save_removals(participants); if saved.is_err() { - participants.entry(identity.to_string()).or_default().removed = before; + participants.insert(identity.to_string(), before); } saved } @@ -266,8 +277,14 @@ impl Room { 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() { @@ -303,20 +320,28 @@ 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 }; - let mut participants = self.participants.lock().unwrap(); - // A new link lets a removed guest back in. - if participants.get(name).is_some_and(|participant| participant.removed) { - self.set_removed(&mut participants, name, false)?; - } participants.entry(name.to_string()).or_default().invite = Some(invite.clone()); drop(participants); self.changed.notify_one(); @@ -331,16 +356,16 @@ impl Room { .get(identity) .is_some_and(|participant| participant.removed) { - self.set_removed(&mut participants, identity, true)?; + self.change(&mut participants, identity, |participant| participant.removed = true)?; } drop(participants); self.changed.notify_one(); Ok(()) } - /// Where `identity`'s renewals are published, in the room. - fn renewal_path(identity: &str) -> anyhow::Result { - Ok(Path::new(&format!("{RENEW}/{identity}"))?) + /// 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 @@ -353,9 +378,9 @@ impl Room { /// 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) -> anyhow::Result<(String, kio::clock::Wall)> { + 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).with_context(invalid)?; + 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())?]); @@ -386,11 +411,13 @@ impl Room { Ok((token, expires)) } - /// The guests whose tokens are renewed. - fn renewed(&self) -> Vec { + /// 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, _)| identity.clone()).collect() + renewed + .map(|(identity, participant)| (identity.clone(), participant.generation)) + .collect() } /// Record that the broadcast at `path` in the room went online or offline, @@ -665,6 +692,7 @@ struct Renewals { /// One guest's token track, ended when dropped. struct Renewed { + generation: u64, publication: moq_net::origin::Publication, track: moq_net::track::Producer, } @@ -689,12 +717,13 @@ impl Renewals { /// of those who are not anymore. fn sync(&mut self) { let renewed = self.room.renewed(); - self.tracks.retain(|identity, _| renewed.contains(identity)); - for identity in 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) { + match self.publish(&identity, generation) { Ok(track) => { self.tracks.insert(identity, track); } @@ -703,11 +732,17 @@ impl Renewals { } } - /// The token track for `identity`, holding a fresh token. - fn publish(&self, identity: &str) -> anyhow::Result { - let publication = self.origin.create_broadcast(Room::renewal_path(identity)?)?; + /// 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 { publication, track }; + let mut renewed = Renewed { + generation, + publication, + track, + }; self.write(identity, &mut renewed)?; renewed.publication.announce(Default::default())?; Ok(renewed) @@ -725,7 +760,7 @@ impl Renewals { } fn write(&self, identity: &str, renewed: &mut Renewed) -> anyhow::Result<()> { - let (token, _) = self.room.sign(identity)?; + let (token, _) = self.room.sign(identity, renewed.generation)?; renewed.track.write_frame(None, token)?; Ok(()) } @@ -939,13 +974,14 @@ 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, - [".renew/alice".parse().unwrap()].into_iter().collect(), + [".renew/alice/0".parse().unwrap()].into_iter().collect(), "a guest reads only its own renewals" ); let (issued, expires) = (claims.issued.unwrap(), claims.expires.unwrap()); @@ -1004,6 +1040,7 @@ mod tests { camera: true, screen: false, removed: false, + generation: 0, } ); @@ -1141,7 +1178,7 @@ mod tests { } ); - let token = kio::clock::timeout(TIMEOUT, next_token(&renewals.consume(), "alice")) + let token = kio::clock::timeout(TIMEOUT, next_token(&renewals.consume(), "alice", 0)) .await .expect("the studio renews the guest") .expect("a token"); @@ -1153,7 +1190,7 @@ mod tests { // 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")?, None).await?; + let broadcast = renewals.consume().request(Room::renewal_path("bob", 0)?, None).await?; read_token(broadcast).await }) .await @@ -1162,8 +1199,11 @@ mod tests { } /// The newest token published for `identity` in `origin`, once there is one. - async fn next_token(origin: &moq_net::origin::Consumer, identity: &str) -> anyhow::Result { - let broadcast = origin.follow(Room::renewal_path(identity)?, None)?.available().await?; + 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 } @@ -1198,7 +1238,7 @@ mod tests { join(true); renewals.sync(); - let first = key.verify(&next_token(&consumer, "alice").await.unwrap()).unwrap(); + 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. @@ -1208,7 +1248,7 @@ mod tests { clock.advance(EXPIRY / 2); assert!(ahead(&first) >= EXPIRY / 2, "renewed before the token expires"); renewals.renew(); - let second = key.verify(&next_token(&consumer, "alice").await.unwrap()).unwrap(); + let second = key.verify(&next_token(&consumer, "alice", 0).await.unwrap()).unwrap(); assert!(ahead(&second) >= EXPIRY, "{:?}", ahead(&second)); room.stop_renewing("alice").unwrap(); @@ -1216,9 +1256,16 @@ mod tests { 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(); - next_token(&consumer, "alice").await.unwrap(); + 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(); @@ -1226,7 +1273,7 @@ mod tests { } /// A removed guest stays unrenewed when the studio restarts, until a new - /// link. + /// link, whose generation a restart keeps too. #[test] fn a_removal_outlasts_a_restart() { let dir = tempfile::tempdir().unwrap(); @@ -1253,18 +1300,25 @@ mod tests { assert!(room.participants()["alice"].removed); assert!(room.renewed().is_empty(), "a removed guest is renewed after a restart"); room.invite("alice").unwrap(); - assert_eq!(room.renewed(), ["alice"]); + 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"); + 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. let file = dir.path().join("show.guests.toml"); std::fs::remove_file(&file).unwrap(); std::fs::create_dir(&file).unwrap(); assert!(room.stop_renewing("alice").is_err()); - assert_eq!(room.renewed(), ["alice"]); + assert_eq!(room.renewed(), alice); std::fs::remove_dir(&file).unwrap(); room.stop_renewing("alice").unwrap(); assert!(start().participants()["alice"].removed); From a8f555ddd7fc73fe98e6f346450679b74706e11a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 22:34:31 -0800 Subject: [PATCH 11/12] chore(join): refresh bun.lock for moq fd07f5b's JS packages Co-Authored-By: Claude Opus 5.5 --- bun.lock | 51 +-------------------------------------------------- 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/bun.lock b/bun.lock index 35032ec..e402ed0 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,6 @@ "version": "0.1.0", "devDependencies": { "@types/bun": "^1.4.4", - "rimraf": "^6.1.3", "typescript": "7.0.2", }, }, @@ -44,13 +43,11 @@ "@moq/loc": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "@zod/mini": "^4.6.7", "zod": "^4.6.7", }, "devDependencies": { "@types/bun": "^1.4.4", "@typescript/lib-dom": "npm:@types/web@^0.0.358", - "rimraf": "^6.1.3", "typescript": "7.0.2", }, }, @@ -65,11 +62,9 @@ "@moq/clock": "workspace:^", "@types/bun": "^1.4.4", "fast-check": "^4.10.2", - "rimraf": "^6.1.3", "typescript": "7.0.2", }, "peerDependencies": { - "@zod/mini": "^4.5.0", "zod": "^4.5.0", }, }, @@ -93,7 +88,6 @@ }, "devDependencies": { "@types/bun": "^1.4.4", - "rimraf": "^6.1.3", "typescript": "7.0.2", }, }, @@ -103,11 +97,9 @@ "dependencies": { "@moq/json": "workspace:^", "@moq/net": "workspace:^", - "@zod/mini": "^4.6.7", "zod": "^4.6.7", }, "devDependencies": { - "rimraf": "^6.1.3", "typescript": "7.0.2", }, }, @@ -119,21 +111,17 @@ "@moq/pattern": "workspace:^", "@moq/qmux": "workspace:^", "@moq/signals": "workspace:*", - "async-mutex": "^0.5.0", - "pako": "^3.0.2", + "pako": "^3.0.3", "sorted-btree": "^2.1.0", - "uuid": "^14.0.3", }, "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", - "rimraf": "^6.1.3", "typescript": "7.0.2", }, "peerDependencies": { - "@zod/mini": "^4.5.0", "zod": "^4.5.0", }, }, @@ -142,7 +130,6 @@ "version": "0.2.1", "devDependencies": { "@types/bun": "^1.4.4", - "rimraf": "^6.1.3", "typescript": "7.0.2", }, }, @@ -163,7 +150,6 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.4.4", "@typescript/lib-dom": "npm:@types/web@^0.0.358", - "rimraf": "^6.1.3", "rolldown": "^1.2.15", "typescript": "7.0.2", "vite": "^8.3.5", @@ -178,7 +164,6 @@ "devDependencies": { "@types/bun": "^1.4.4", "@typescript/lib-dom": "npm:@types/web@^0.0.358", - "rimraf": "^6.1.3", "typescript": "7.0.2", }, }, @@ -193,14 +178,12 @@ "@moq/publish": "workspace:^", "@moq/signals": "workspace:^", "@moq/watch": "workspace:^", - "@zod/mini": "^4.6.7", "zod": "^4.6.7", }, "devDependencies": { "@types/audioworklet": "^0.0.100", "@types/bun": "^1.4.4", "@typescript/lib-dom": "npm:@types/web@^0.0.358", - "rimraf": "^6.1.3", "typescript": "7.0.2", "vite": "^8.3.5", }, @@ -219,7 +202,6 @@ "fast-check": "^4.10.2", "react": "^19.3.1", "react-dom": "^19.3.1", - "rimraf": "^6.1.3", "solid-js": "^1.9.17", "typescript": "7.0.2", }, @@ -243,7 +225,6 @@ "devDependencies": { "@types/bun": "^1.4.4", "@typescript/lib-dom": "npm:@types/web@^0.0.358", - "rimraf": "^6.1.3", "typescript": "7.0.2", "vite": "^8.3.5", }, @@ -258,7 +239,6 @@ "@moq/clock": "workspace:^", "@types/bun": "^1.4.4", "@typescript/lib-dom": "npm:@types/web@^0.0.358", - "rimraf": "^6.1.3", "typescript": "7.0.2", }, }, @@ -280,7 +260,6 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.4.4", "@typescript/lib-dom": "npm:@types/web@^0.0.358", - "rimraf": "^6.1.3", "rolldown": "^1.2.15", "typescript": "7.0.2", "vite": "^8.3.5", @@ -312,8 +291,6 @@ "@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=="], - "@isaacs/nonrecursive-glob-match": ["@isaacs/nonrecursive-glob-match@1.0.3", "", {}, "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA=="], - "@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=="], @@ -446,14 +423,6 @@ "@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=="], - - "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - "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=="], @@ -470,8 +439,6 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "glob": ["glob@14.0.3", "", { "dependencies": { "minimatch": "^11.0.2", "minipass": "^7.1.3", "path-scurry": "^3.0.0" } }, "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow=="], - "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=="], @@ -498,24 +465,14 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], - "media-captions": ["media-captions@0.0.18", "", {}, "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ=="], "mediabunny": ["mediabunny@1.65.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-TNnDNOmD5Fme4rwMGWuNv485a9iQwd9lgw88PI9zQ0FMnzEUoo6EJpG7ZhdJ2iwZFWo8AzNkP1KPq5IAHaktKw=="], - "minimatch": ["minimatch@11.0.4", "", { "dependencies": { "@isaacs/nonrecursive-glob-match": "^1.0.3", "brace-expansion": "^5.0.12" } }, "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg=="], - - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], - "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "pako": ["pako@3.0.3", "", {}, "sha512-tmrebBBYI5FOw8QICGwJNqE8fotC8R+gwcxOvoFNv+GKehOI6Lc6YBolNcQNnC4v96l1wt7rh102ifD4kVeZAQ=="], - "path-scurry": ["path-scurry@3.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -528,8 +485,6 @@ "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], - "rimraf": ["rimraf@6.1.4", "", { "dependencies": { "glob": "^14.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-Fk3reQDVPZUMbEm7Zwxz03BWQMW3V5buSk9aU8OckTrO8A1nQtbqvYsAantGNermwsIsXcuOBqcoJ3HuMELctw=="], - "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=="], @@ -546,14 +501,10 @@ "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.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "uuid": ["uuid@14.0.3", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw=="], - "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=="], From d30e16c959c3a13df6a46cdc06cbd70c322093f5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 22:58:20 -0800 Subject: [PATCH 12/12] fix(studio): keep guest removals beside the room key, not the scene file A removal bounds tokens the room key signed, but it was kept beside the scene file, so opening another scene file for the same room renewed a removed guest again. Keep it beside the room key instead (`room.guests.toml` for `room.jwk`), and bring the renewal path's docs up to `.renew//`. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++-- quest/m0/guest-return.md | 2 +- rs/moq-studio/src/guests.rs | 65 +++++++++++++++++-------------------- rs/moq-studio/src/main.rs | 5 +-- 4 files changed, 36 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index e6a50a3..a810125 100644 --- a/README.md +++ b/README.md @@ -222,9 +222,10 @@ who reconnects after you removed their source comes back under a new name 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 it also stops renewing theirs, and they are disconnected within `--guest-expiry`. That -holds across a restart: removed guests are kept beside the scene file, as -`show.guests.toml` for `show.toml`. Undo puts them back in the scenes but not -on renewal; **New link** lets them back in and renews them again, under a new +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 diff --git a/quest/m0/guest-return.md b/quest/m0/guest-return.md index e89de61..74088b2 100644 --- a/quest/m0/guest-return.md +++ b/quest/m0/guest-return.md @@ -13,7 +13,7 @@ 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. A guest's token subscribes only -to its own renewals (`.renew/`), so it must also grant the feed's +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 diff --git a/rs/moq-studio/src/guests.rs b/rs/moq-studio/src/guests.rs index 2d75de0..6aba75a 100644 --- a/rs/moq-studio/src/guests.rs +++ b/rs/moq-studio/src/guests.rs @@ -12,10 +12,11 @@ //! //! 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, so their last token -//! bounds how long they stay. +//! `--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; @@ -33,8 +34,8 @@ use crate::engine::Engine; /// The domain `.github/workflows/join.yml` deploys `js/join` to. const JOIN_PAGE: &str = "https://join.studio.moq.dev/"; -/// Where each guest's renewed tokens are published: `.renew/`, which -/// moq-room's announcements hide. `js/join` reads the same path. +/// 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 @@ -76,9 +77,8 @@ pub struct Config { impl Config { /// The room these flags describe on the relay at `connect`, or `None` without - /// `--room`, remembering who was removed from scenes beside the scene file - /// at `scenes`. - pub fn load(&self, connect: Option<&Url>, scenes: &std::path::Path) -> anyhow::Result> { + /// `--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), (Some(room), Some(key), _) => (room, key), @@ -89,18 +89,20 @@ 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.remember(removals_path(scenes))?)) + Ok(Some(room.remember(removals)?)) } } /// Where the guests removed from scenes, and how often each was let back in, -/// are kept: `show.guests.toml` for `show.toml`, beside its history. -fn removals_path(scenes: &std::path::Path) -> PathBuf { - let mut name = scenes.file_stem().unwrap_or_default().to_os_string(); +/// 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"); - scenes.with_file_name(name) + key.with_file_name(name) } /// The file at [`removals_path`]. @@ -811,7 +813,6 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("room.jwk"); key("show/guests", &["**"], &["**"]).to_file(&path).unwrap(); - let scenes = dir.path().join("show.toml"); let connect = Url::parse("https://relay.example/show").unwrap(); let config = |room: bool, key: bool, page: bool| Config { room: room.then(|| "guests".into()), @@ -820,31 +821,23 @@ mod tests { guest_expiry: EXPIRY.into(), }; - assert!( - config(false, false, false) - .load(Some(&connect), &scenes) - .unwrap() - .is_none() - ); + assert!(config(false, false, false).load(Some(&connect)).unwrap().is_none()); for partial in [ config(true, false, true), config(false, true, true), config(false, false, true), ] { - let err = partial.load(Some(&connect), &scenes).err().unwrap(); + let err = partial.load(Some(&connect)).err().unwrap(); assert!(err.to_string().contains("need both"), "{err}"); } - let err = config(true, true, true).load(None, &scenes).err().unwrap(); + let err = config(true, true, true).load(None).err().unwrap(); assert!(err.to_string().contains("--connect"), "{err}"); - let room = config(true, true, true).load(Some(&connect), &scenes).unwrap().unwrap(); + let room = config(true, true, true).load(Some(&connect)).unwrap().unwrap(); assert_eq!(room.path().as_str(), "guests"); let link = room.invite("alice").unwrap().link; assert!(link.as_str().starts_with("https://studio.example/join/#"), "{link}"); - let room = config(true, true, false) - .load(Some(&connect), &scenes) - .unwrap() - .unwrap(); + let room = config(true, true, false).load(Some(&connect)).unwrap().unwrap(); let link = room.invite("alice").unwrap().link; assert!(link.as_str().starts_with(JOIN_PAGE), "{link}"); } @@ -1272,14 +1265,15 @@ mod tests { assert!(renewals.tracks.is_empty(), "a guest who left is not renewed"); } - /// A removed guest stays unrenewed when the studio restarts, until a new - /// link, whose generation a restart keeps too. + /// 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(); - let path = dir.path().join("room.jwk"); + 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 scenes = dir.path().join("show.toml"); let connect = Url::parse("https://relay.example/show").unwrap(); let config = Config { room: Some("guests".into()), @@ -1287,13 +1281,15 @@ mod tests { join_page: None, guest_expiry: EXPIRY.into(), }; - let start = || config.load(Some(&connect), &scenes).unwrap().unwrap(); + // 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(); - assert!(dir.path().join("show.guests.toml").exists()); + let file = dir.path().join("keys/room.guests.toml"); + assert!(file.exists()); let room = start(); room.observe(&camera, true, &Collection::new(), &History::new()); @@ -1314,7 +1310,6 @@ mod tests { ); // A removal the file cannot hold is not made, so trying again saves it. - let file = dir.path().join("show.guests.toml"); std::fs::remove_file(&file).unwrap(); std::fs::create_dir(&file).unwrap(); assert!(room.stop_renewing("alice").is_err()); diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 4d76e51..d7500e8 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -139,10 +139,7 @@ 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(), &config.scenes)? - .map(Arc::new); + 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());