From 662697633113e3c2ce019d8db9e0797d4fed8ace Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 20 Sep 2026 06:43:51 -0700 Subject: [PATCH 1/3] Pre-publish verification against pinned moq main Update the smoke harness for the post-#3793 API surface and pin the unpublished revision under test, so the matrix can go green BEFORE packages are published: - smoke.toml: [server] listen -> [listen] bind, auth public '' -> '**' - smoke.sh: moq --client-connect -> --connect - dev.sh: default ref dev -> main, add MOQ_PIN (pinned to the #3793 merge) with --pin override and a revision-mismatch warning - clients/dev: Announce.Event { pattern, active } -> Update { path, kind }; Origin.Producer -> Origin.Table - token.sh: support the 'moq auth' subcommand when TOKEN resolves to the moq binary; MOQ_SRC drives source @moq/auth (generate --out) instead of published @moq/token (generate --key) - CI from-dev job checks out moq main instead of the deleted dev branch Verified: dev.sh contract cases pass; rust/python/go/js-native/browser media cells pass against a source-built relay; token cross-verifies rust/js-node/js-bun from source. Published @moq/token <-> new moq auth is a confirmed pre-publish break (claims schema changed). --- .github/workflows/smoke.yml | 30 ++++++ README.md | 12 ++- clients/dev/catalog.ts | 105 ++++++++++++++++++++ clients/dev/lib.ts | 78 +++++++++++++++ clients/dev/live.ts | 97 +++++++++++++++++++ clients/dev/run.ts | 34 +++++++ clients/dev/stats.ts | 95 +++++++++++++++++++ dev.sh | 184 ++++++++++++++++++++++++++++++++++++ freshness.sh | 9 ++ justfile | 8 ++ smoke.sh | 4 +- smoke.toml | 7 +- token.sh | 98 ++++++++++++++----- 13 files changed, 732 insertions(+), 29 deletions(-) create mode 100644 clients/dev/catalog.ts create mode 100644 clients/dev/lib.ts create mode 100644 clients/dev/live.ts create mode 100644 clients/dev/run.ts create mode 100644 clients/dev/stats.ts create mode 100755 dev.sh diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 99524ba..d0bf19a 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -274,3 +274,33 @@ jobs: # failing the job; remove this once that protocol mismatch is fixed. continue-on-error: true run: ./moxygen.sh + + from-dev: + name: Unpublished dev API + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 45 + + steps: + - name: Checkout smoke + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Checkout moq (main, pinned pre-publish revision) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: moq-dev/moq + ref: main + path: moq + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: System deps + run: sudo apt-get update && sudo apt-get install -y pkg-config cmake g++ + + - name: From-dev contract cases + run: ./dev.sh --src "$PWD/moq" --timeout 30 diff --git a/README.md b/README.md index 8442928..a870ca5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Cross-language interop smoke test for the **public** [Media over QUIC](https://g The [moq-dev/moq](https://github.com/moq-dev/moq) monorepo has its own in-tree smoke test, but it builds every client from workspace source. That proves the code in the tree works; it does **not** prove a real user can install the published artifacts and have them talk to each other. A missing wheel, a stale Homebrew formula, a broken `.deb`, an export that didn't survive packaging, a Go module missing its header. none of that shows up until someone installs from a registry. -This repo installs each client straight from its public package registry, stands up a relay, and runs the interop matrix: +This repo installs each client straight from its public package registry, stands up a relay, and runs the interop matrix. A second **from-dev** channel (`./dev.sh`) installs the unpublished `main` surface from a moq checkout (path or git `main` at a pinned `MOQ_PIN` revision) and runs contract cases the published matrix cannot see yet: reconnecting `Connection` handles, announcements, credential refresh, publication replacement, catalog snapshots-then-deltas, and stats Snapshot versus Window. Embedded relay ownership stays in moq-relay; it is not a smoke client. - A relay (`moq-relay`) routes broadcasts. - For each publisher language, publish an H.264 broadcast. @@ -84,14 +84,21 @@ RELAY_BIN=/path/to/moq-relay MOQ_BIN=/path/to/moq ./smoke.sh # prove the harness can fail: no publisher, every subscriber must time out. ./smoke.sh --negative --subscribers rust,python + +# unpublished dev API (JS packages + relay from a moq checkout, not npm/crates.io): +just dev --src /path/to/moq +just dev # clones github.com/moq-dev/moq (main, pinned via MOQ_PIN) ``` `smoke.sh` installs the language clients (PyPI / Go proxy / npm) into a scratch dir on each run, so you always test the latest published versions. It does **not** install the Rust binaries; that is the channel under test. +`dev.sh` is the other way around: it builds `moq-relay` from `MOQ_SRC` (or clones `main` at `MOQ_PIN`) and resolves `@moq/net`, `@moq/hang`, and `@moq/json` from that checkout's `js/` tree so the contract cases exercise the unpublished surface. `token.sh` accepts the same `MOQ_SRC` to drive the source `@moq/auth` CLI (which renamed `generate --key` to `--out`) against a source-built `moq auth` binary. + ## Layout ``` -smoke.sh orchestrator: relay + media interop matrix +smoke.sh orchestrator: relay + media interop matrix (published packages) +dev.sh orchestrator: unpublished dev API contract cases (path/git `main`, pinned) cloudflare.sh orchestrator: Cloudflare client through both projects' relays moxygen.sh orchestrator: moxygen protocol client through the moq-dev relay smoke.toml relay config (anonymous, self-signed localhost) @@ -105,6 +112,7 @@ clients/ kotlin/ subscribe via dev.moq:moq (Gradle/JVM) c/subscribe.c subscribe via libmoq (prebuilt release) js-native/subscribe.ts subscribe via @moq/net + @moq/hang + WebTransport polyfill (node, bun) + dev/ from-dev contract cases: Connection, catalog Snapshot, stats Snapshot vs Window (gst) subscribe via the moq-gst plugin (moqsrc); no client dir, driven by gst-launch docker/ moq-relay + moq wrappers: docker run the moqdev/* images (the docker channel) token/js/ installs @moq/token (npm) for token.sh to drive under node + bun diff --git a/clients/dev/catalog.ts b/clients/dev/catalog.ts new file mode 100644 index 0000000..6ff4ea1 --- /dev/null +++ b/clients/dev/catalog.ts @@ -0,0 +1,105 @@ +// Catalog-only reading: Json.Snapshot.Consumer plus the hang catalog schema. +// A full snapshot is followed by a merge-patch delta. Parsing every frame as a +// catalog is the old consumer path and must fail on the delta frame. +import * as Catalog from "@moq/hang/catalog"; +import * as Json from "@moq/json"; +import * as Moq from "@moq/net"; +import { connected, equal, handle, REPLAY_MS, waitActive, waitAnnounce } from "./lib.ts"; + +const PATH = "dev.catalog"; + +const SNAPSHOT: Catalog.Root = { + json: { + tracks: { + status: { mode: "snapshot" }, + }, + }, +}; + +const UPDATED: Catalog.Root = { + json: { + tracks: { + extra: { mode: "snapshot" }, + }, + }, +}; + +export async function catalog(url: URL): Promise { + const pub = handle(url); + const sub = handle(url); + try { + const pubOrigin = await connected(pub); + const subOrigin = await connected(sub); + const announced = sub.announced(); + + const broadcast = pubOrigin.createBroadcast(Moq.Path.from(PATH)); + const track = broadcast.createTrack(Catalog.TRACK); + const producer = new Json.Snapshot.Producer({ + track, + schema: Catalog.RootSchema, + deltaRatio: 100, + }); + broadcast.announce(); + + await waitAnnounce(announced, PATH, true); + const request = subOrigin.request(Moq.Path.from(PATH)); + const consumer = await waitActive(request, "catalog broadcast"); + + const snapshot = new Json.Snapshot.Consumer({ + track: consumer.track(Catalog.TRACK).subscribe({ + priority: Catalog.PRIORITY.catalog, + maxAge: REPLAY_MS, + }), + schema: Catalog.RootSchema, + }); + + producer.update(SNAPSHOT); + const first = await snapshot.next(); + if (!first || !equal(first, SNAPSHOT)) { + throw new Error(`first catalog was ${JSON.stringify(first)}`); + } + + producer.update(UPDATED); + const second = await snapshot.next(); + if (!second || !equal(second, UPDATED)) { + throw new Error(`delta did not reconstruct ${JSON.stringify(second)}`); + } + + producer.finish(); + + const raw = consumer + .track(Catalog.TRACK) + .subscribe({ priority: Catalog.PRIORITY.catalog, maxAge: REPLAY_MS }) + .ordered(); + const group = await raw.nextGroup(); + if (!group) throw new Error("catalog group missing"); + const frames: Uint8Array[] = []; + for (;;) { + const frame = await group.readFrame(); + if (!frame) break; + frames.push(frame.payload); + } + if (frames.length < 2) { + throw new Error(`expected a snapshot frame then a delta, got ${frames.length} frame(s)`); + } + + const decoder = new TextDecoder(); + const root = Catalog.RootSchema.parse(JSON.parse(decoder.decode(frames[0]))); + if (!equal(root, SNAPSHOT)) throw new Error("frame 0 was not the full catalog snapshot"); + + let deltaParsedAsCatalog = false; + try { + Catalog.RootSchema.parse(JSON.parse(decoder.decode(frames[1]))); + deltaParsedAsCatalog = true; + } catch { + // The delta is an RFC 7396 merge patch, not a catalog root. + } + if (deltaParsedAsCatalog) { + throw new Error("frame 1 parsed as a full catalog; the consumer is not reconstructing deltas"); + } + } finally { + pub.close(); + sub.close(); + } + console.log(" catalog: ok"); +} diff --git a/clients/dev/lib.ts b/clients/dev/lib.ts new file mode 100644 index 0000000..0600f74 --- /dev/null +++ b/clients/dev/lib.ts @@ -0,0 +1,78 @@ +// Shared helpers for the from-dev API contract cases. +import * as Moq from "@moq/net"; + +export const REPLAY_MS = 30_000; + +export function parseUrl(): { url: URL; timeoutMs: number } { + const args = process.argv.slice(2); + let url: string | undefined; + let timeout = "30"; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--url") url = args[++i]; + else if (args[i] === "--timeout") timeout = args[++i] ?? timeout; + } + if (!url) { + console.error("usage: run.ts --url URL [--timeout S]"); + process.exit(2); + } + return { url: new URL(url), timeoutMs: Number.parseFloat(timeout) * 1000 }; +} + +export async function waitUntil(pred: () => boolean, label: string, ms = 10_000): Promise { + const deadline = Date.now() + ms; + for (;;) { + if (pred()) return; + if (Date.now() > deadline) throw new Error(`timeout waiting for ${label}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +export async function connected(conn: Moq.Connection, ms = 10_000): Promise { + await waitUntil( + () => conn.status.peek() === "connected" && conn.origin.peek() !== undefined, + "connection established", + ms, + ); + const origin = conn.origin.peek(); + if (!origin) throw new Error("connected without an origin"); + return origin; +} + +export function announcedPath(entry: Moq.Announce.Update): string { + // Path.Valid is a branded string; the announcement path is already relative + // to the session origin. + return entry.path as string; +} + +export async function waitAnnounce( + announced: Moq.Announce.Consumer, + path: string, + active: boolean, +): Promise { + for (;;) { + const entry = await announced.next(); + if (!entry) throw new Error(`announce stream ended before ${path} ${active ? "appeared" : "retracted"}`); + const isActive = entry.kind !== "retracted"; + if (announcedPath(entry) === path && isActive === active) return; + } +} + +export async function waitActive( + request: Moq.Origin.Request, + label: string, + ms = 10_000, +): Promise { + await waitUntil(() => request.active.peek() !== undefined, label, ms); + const broadcast = request.active.peek(); + if (!broadcast) throw new Error(`${label}: request resolved then vanished`); + return broadcast; +} + +export function equal(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function handle(url: URL): Moq.Connection { + // Private loops so a publisher and a subscriber do not share an origin and skip the relay. + return new Moq.Connection({ url, share: false, linger: 0 }); +} diff --git a/clients/dev/live.ts b/clients/dev/live.ts new file mode 100644 index 0000000..70f13d8 --- /dev/null +++ b/clients/dev/live.ts @@ -0,0 +1,97 @@ +// Connection, announcements, credential refresh, and publication replacement. +// Models the moq.pro live session (reconnecting handle, URL swap, announce cursor) +// without copying that app. +import * as Moq from "@moq/net"; +import { connected, handle, waitActive, waitAnnounce, waitUntil } from "./lib.ts"; + +const PATH = "dev.live"; + +export async function live(url: URL): Promise { + await refresh(url); + await announceAndReplace(url); + console.log(" live: ok"); +} + +async function refresh(url: URL): Promise { + const first = new URL(url.href); + first.searchParams.set("jwt", "first"); + const second = new URL(url.href); + second.searchParams.set("jwt", "second"); + + const conn = handle(first); + try { + await connected(conn); + if (conn.closed.peek() !== undefined) { + throw new Error("closed settled before the handle was released"); + } + + conn.url.set(second); + await waitUntil( + () => conn.url.peek()?.href === second.href && conn.status.peek() === "connected", + "connected at the refreshed URL", + ); + if (conn.closed.peek() !== undefined) { + throw new Error("closed settled on a URL swap; it is handle disposal, not session end"); + } + if (conn.error.peek() !== undefined) { + throw new Error(`error after a recoverable URL swap: ${conn.error.peek()}`); + } + } finally { + conn.close(); + } + + const closed = await conn.closed; + if (closed !== null) throw new Error(`close() settled with ${closed}`); +} + +async function announceAndReplace(url: URL): Promise { + const pub = handle(url); + const sub = handle(url); + try { + const pubOrigin = await connected(pub); + const subOrigin = await connected(sub); + const announced = sub.announced(); + + const first = publish(pubOrigin, PATH, "v1"); + await waitAnnounce(announced, PATH, true); + + const request = subOrigin.request(Moq.Path.from(PATH)); + const consumer = await waitActive(request, "first publication"); + await expectFrame(consumer, "v1"); + + first.broadcast.close(); + await waitAnnounce(announced, PATH, false); + + const second = publish(pubOrigin, PATH, "v2"); + await waitAnnounce(announced, PATH, true); + const replaced = await waitActive(request, "replaced publication"); + await expectFrame(replaced, "v2"); + + second.broadcast.close(); + } finally { + pub.close(); + sub.close(); + } +} + +function publish(origin: Moq.Origin.Table, path: string, payload: string) { + const broadcast = origin.createBroadcast(Moq.Path.from(path)); + const track = broadcast.createTrack("messages"); + const group = track.appendGroup(); + group.writeString(payload); + group.close(); + broadcast.announce(); + return { broadcast, track }; +} + +async function expectFrame(broadcast: Moq.Broadcast.Consumer, payload: string): Promise { + const track = broadcast.track("messages").subscribe({ priority: 0, maxAge: 30_000 }); + try { + const group = await track.recvGroup(); + if (!group) throw new Error("track ended before a group arrived"); + const frame = await group.readString(); + if (frame !== payload) throw new Error(`expected ${payload}, got ${frame}`); + } finally { + track.close(); + } +} diff --git a/clients/dev/run.ts b/clients/dev/run.ts new file mode 100644 index 0000000..ab6e7f5 --- /dev/null +++ b/clients/dev/run.ts @@ -0,0 +1,34 @@ +// From-dev API contract cases. Run against a local relay with JS packages +// resolved from a moq checkout, not npm latest. +import { install } from "@moq/web-transport"; +import { catalog } from "./catalog.ts"; +import { parseUrl } from "./lib.ts"; +import { live } from "./live.ts"; +import { stats } from "./stats.ts"; + +install(); + +const { url, timeoutMs } = parseUrl(); + +let timeoutId: ReturnType | undefined; +const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("timed out waiting for the from-dev cases")), timeoutMs); +}); + +try { + await Promise.race([ + (async () => { + console.log("from-dev cases against", url.href); + await live(url); + await catalog(url); + await stats(url); + console.log("from-dev: ok"); + })(), + timeout, + ]); +} catch (err) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; +} finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); +} diff --git a/clients/dev/stats.ts b/clients/dev/stats.ts new file mode 100644 index 0000000..a583795 --- /dev/null +++ b/clients/dev/stats.ts @@ -0,0 +1,95 @@ +// Stats snapshots versus retained rollup groups. +// Live counters are a lossy latest-value Snapshot. Billing rollups are a Window: +// a late joiner still sees the retained buckets, and pops drop the front. +import * as Json from "@moq/json"; +import * as Moq from "@moq/net"; +import { connected, handle, REPLAY_MS, waitActive, waitAnnounce } from "./lib.ts"; + +const PATH = "dev.stats"; + +type Snapshot = { bytes: number }; +type Bucket = { start: number; bytes: number }; + +function pushedBytes(event: Json.Window.Event | undefined): number | undefined { + return event && "push" in event ? event.push.value.bytes : undefined; +} + +export async function stats(url: URL): Promise { + const pub = handle(url); + const sub = handle(url); + try { + const pubOrigin = await connected(pub); + const subOrigin = await connected(sub); + const announced = sub.announced(); + + const broadcast = pubOrigin.createBroadcast(Moq.Path.from(PATH)); + const liveTrack = broadcast.createTrack("live.json"); + const rollupTrack = broadcast.createTrack("minute"); + const live = new Json.Snapshot.Producer({ track: liveTrack, deltaRatio: 100 }); + const rollup = new Json.Window.Producer({ track: rollupTrack }); + broadcast.announce(); + + await waitAnnounce(announced, PATH, true); + const request = subOrigin.request(Moq.Path.from(PATH)); + const consumer = await waitActive(request, "stats broadcast"); + + const liveReader = new Json.Snapshot.Consumer({ + track: consumer.track("live.json").subscribe({ priority: 0, maxAge: REPLAY_MS }), + }); + const rollupReader = new Json.Window.Consumer({ + track: consumer.track("minute").subscribe({ priority: 0, maxAge: REPLAY_MS }), + }); + + live.update({ bytes: 1 }); + const firstLive = await liveReader.next(); + if (firstLive?.bytes !== 1) throw new Error(`live snapshot 1: ${JSON.stringify(firstLive)}`); + + live.update({ bytes: 2 }); + const secondLive = await liveReader.next(); + if (secondLive?.bytes !== 2) throw new Error(`live snapshot 2: ${JSON.stringify(secondLive)}`); + + rollup.push({ start: 0, bytes: 10 }); + const push0 = await rollupReader.next(); + if (!push0 || !("push" in push0) || push0.push.value.bytes !== 10) { + throw new Error(`rollup push 0: ${JSON.stringify(push0)}`); + } + + rollup.push({ start: 1, bytes: 20 }); + const push1 = await rollupReader.next(); + if (!push1 || !("push" in push1) || push1.push.index !== 1) { + throw new Error(`rollup push 1: ${JSON.stringify(push1)}`); + } + + rollup.pop(1); + const pop = await rollupReader.next(); + if (!pop || !("pop" in pop) || pop.pop.start !== 0 || pop.pop.end !== 1) { + throw new Error(`rollup pop: ${JSON.stringify(pop)}`); + } + + // A late snapshot joiner collapses to the latest value. A late window + // joiner is restated the retained suffix, not only the live tail. + const lateLive = new Json.Snapshot.Consumer({ + track: consumer.track("live.json").subscribe({ priority: 0, maxAge: REPLAY_MS }), + }); + const lateLiveValue = await lateLive.next(); + if (lateLiveValue?.bytes !== 2) { + throw new Error(`late snapshot joiner: ${JSON.stringify(lateLiveValue)}`); + } + + const lateRollup = new Json.Window.Consumer({ + track: consumer.track("minute").subscribe({ priority: 0, maxAge: REPLAY_MS }), + }); + const first = await lateRollup.next(); + const second = pushedBytes(first) === 20 ? first : await lateRollup.next(); + if (pushedBytes(first) !== 20 && pushedBytes(second) !== 20) { + throw new Error(`late window joiner never saw the retained bucket: ${JSON.stringify([first, second])}`); + } + + live.finish(); + rollup.finish(); + } finally { + pub.close(); + sub.close(); + } + console.log(" stats: ok"); +} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..0268b0b --- /dev/null +++ b/dev.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Prove the unpublished API by installing JS packages from a moq checkout +# (path or git `main` at a pinned revision) and running contract cases a +# published-package matrix cannot see yet. The cargo/apt/brew/nix/docker media +# matrix is unchanged: this is a second channel, not a replacement. +# +# MOQ_SRC=/path/to/moq ./dev.sh +# ./dev.sh --src /path/to/moq +# ./dev.sh # clones github.com/moq-dev/moq (main, pinned) into a temp dir +set -euo pipefail + +SMOKE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +CLIENTS="$SMOKE_DIR/clients/dev" +TIMEOUT="${SMOKE_TIMEOUT:-30}" +PORT="${SMOKE_PORT:-}" +MOQ_SRC="${MOQ_SRC:-}" +MOQ_GIT="${MOQ_GIT:-https://github.com/moq-dev/moq.git}" +MOQ_REF="${MOQ_REF:-main}" +# Pinned pre-publish revision: the merge that introduced the breaking API +# surface under test. Override with --ref / MOQ_REF to move it forward. +MOQ_PIN="${MOQ_PIN:-5d0991b9991305be907e6c0682a4e276722eeed0}" + +require_value() { + if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then + echo "error: $1 requires a value" >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --src) + require_value "$@" + MOQ_SRC="$2" + shift 2 + ;; + --git) + require_value "$@" + MOQ_GIT="$2" + shift 2 + ;; + --ref) + require_value "$@" + MOQ_REF="$2" + shift 2 + ;; + --pin) + require_value "$@" + MOQ_PIN="$2" + shift 2 + ;; + --timeout) + require_value "$@" + TIMEOUT="$2" + shift 2 + ;; + --port) + require_value "$@" + PORT="$2" + shift 2 + ;; + *) + echo "unknown arg: $1" >&2 + exit 2 + ;; + esac +done + +have() { command -v "$1" >/dev/null 2>&1; } + +kill_tree() { + local pid="$1" child + for child in $(pgrep -P "$pid" 2>/dev/null || true); do kill_tree "$child"; done + kill -KILL "$pid" 2>/dev/null || true +} + +TMP=$(mktemp -d) +RELAY_PID="" +cleanup() { + [[ -n "${RELAY_PID:-}" ]] && kill_tree "$RELAY_PID" + rm -rf "$TMP" +} +trap cleanup EXIT + +for t in bun cargo curl git pgrep; do + have "$t" || { + echo "error: missing $t" >&2 + exit 1 + } +done + +if [[ -z "$MOQ_SRC" ]]; then + echo "cloning $MOQ_GIT ($MOQ_REF @ ${MOQ_PIN:-HEAD})..." + git clone --depth 1 --branch "$MOQ_REF" "$MOQ_GIT" "$TMP/moq" + MOQ_SRC="$TMP/moq" + if [[ -n "${MOQ_PIN:-}" ]]; then + git -C "$MOQ_SRC" fetch --depth 1 origin "$MOQ_PIN" && git -C "$MOQ_SRC" checkout "$MOQ_PIN" + fi +elif [[ ! -d "$MOQ_SRC" ]]; then + echo "error: --src is not a directory: $MOQ_SRC" >&2 + exit 1 +fi +MOQ_SRC=$(cd "$MOQ_SRC" && pwd) + +echo "moq: $MOQ_SRC" +echo "revision: $(git -C "$MOQ_SRC" rev-parse HEAD)" +echo "ref: $(git -C "$MOQ_SRC" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)" +if [[ -n "${MOQ_PIN:-}" ]] && [[ "$(git -C "$MOQ_SRC" rev-parse HEAD)" != "$MOQ_PIN" ]]; then + echo "warn: MOQ_SRC revision $(git -C "$MOQ_SRC" rev-parse --short HEAD) != pinned $MOQ_PIN (testing unpinned source)" >&2 +fi + +RELAY="${RELAY_BIN:-}" +if [[ -z "$RELAY" ]]; then + if [[ -x "$MOQ_SRC/target/debug/moq-relay" ]]; then + RELAY="$MOQ_SRC/target/debug/moq-relay" + else + echo "building moq-relay from source..." + (cd "$MOQ_SRC" && cargo build -p moq-relay) >"$TMP/cargo.log" 2>&1 || { + sed 's/^/ cargo: /' "$TMP/cargo.log" >&2 + exit 1 + } + RELAY="$MOQ_SRC/target/debug/moq-relay" + fi +fi +if [[ ! -x "$RELAY" ]]; then + echo "error: moq-relay not found at $RELAY" >&2 + exit 1 +fi +echo "relay: $RELAY" + +# Resolve unpublished @moq/* from the moq workspace, and @moq/web-transport +# from npm. bun workspace globs skip the js/* symlinks, so link the packages +# into this client's node_modules after bun install in both trees. +echo "installing JS packages from $MOQ_SRC/js ..." +if ! (cd "$MOQ_SRC" && bun install) >"$TMP/bun-moq.log" 2>&1; then + sed 's/^/ bun moq: /' "$TMP/bun-moq.log" >&2 + exit 1 +fi +mkdir -p "$TMP/cases" +cp "$CLIENTS"/*.ts "$TMP/cases/" +cat >"$TMP/cases/package.json" <<'EOF' +{ + "name": "moq-dev-api", + "private": true, + "type": "module", + "dependencies": { + "@moq/web-transport": "latest" + } +} +EOF +if ! (cd "$TMP/cases" && bun install) >"$TMP/bun-cases.log" 2>&1; then + sed 's/^/ bun cases: /' "$TMP/bun-cases.log" >&2 + exit 1 +fi +mkdir -p "$TMP/cases/node_modules/@moq" +for pkg in net hang json flate signals pattern loc; do + [[ -d "$MOQ_SRC/js/$pkg" ]] || { + echo "error: missing $MOQ_SRC/js/$pkg" >&2 + exit 1 + } + ln -sfn "$MOQ_SRC/js/$pkg" "$TMP/cases/node_modules/@moq/$pkg" +done + +if [[ -z "$PORT" ]]; then + PORT=$(bun -e 'const n=require("node:net"); const s=n.createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})') +fi +URL="http://127.0.0.1:${PORT}" + +sed "s/4443/${PORT}/g" "$SMOKE_DIR/smoke.toml" >"$TMP/relay.toml" +echo "starting relay on 127.0.0.1:${PORT}..." +"$RELAY" "$TMP/relay.toml" >"$TMP/relay.log" 2>&1 & +RELAY_PID=$! +for _ in $(seq 1 60); do + curl -sf "$URL/certificate.sha256" >/dev/null 2>&1 && break + sleep 0.5 +done +if ! curl -sf "$URL/certificate.sha256" >/dev/null 2>&1; then + echo "relay never became ready" >&2 + sed 's/^/ relay: /' "$TMP/relay.log" >&2 || true + exit 1 +fi + +echo "running from-dev cases..." +(cd "$TMP/cases" && bun run.ts --url "$URL" --timeout "$TIMEOUT") diff --git a/freshness.sh b/freshness.sh index a32361d..deed4c0 100755 --- a/freshness.sh +++ b/freshness.sh @@ -139,6 +139,15 @@ else note FAIL "dev.moq:moq is not a dynamic latest version in build.gradle.kts" fail=1 fi +# From-dev consumes a moq checkout, not npm/crates.io latest. The published +# matrix above stays on latest; this channel is what proves unpublished `main`. +# shellcheck disable=SC2016 +if grep -q 'ln -sfn "$MOQ_SRC/js/$pkg"' dev.sh && grep -q 'MOQ_REF:-main}' dev.sh && grep -q 'MOQ_PIN:-' dev.sh; then + note ok "from-dev JS packages -> MOQ_SRC/js (git ref default: main, pinned)" +else + note FAIL "dev.sh no longer installs unpublished JS packages from a moq checkout" + fail=1 +fi # C: smoke.sh resolves the newest libmoq-v* release, never a fixed version. if grep -q "grep '\^libmoq-v' | head -1" smoke.sh; then note ok "libmoq -> latest release" diff --git a/justfile b/justfile index ca00948..58d647a 100644 --- a/justfile +++ b/justfile @@ -72,6 +72,14 @@ nix-channel *args: negative *args: ./smoke.sh --negative {{ args }} +# Unpublished API: install JS packages from a moq checkout (path or git +# `main` at a pinned revision) and run the contract cases. The +# published-package matrix is unchanged. +# just dev --src /path/to/moq +# just dev # clones github.com/moq-dev/moq (main, pinned) +dev *args: + ./dev.sh {{ args }} + # Assert we pin nothing stale: no committed lock files, and any version we are # forced to pin (npm playwright) matches what the toolchain provides. freshness: diff --git a/smoke.sh b/smoke.sh index 6fac63c..d59aa51 100755 --- a/smoke.sh +++ b/smoke.sh @@ -624,7 +624,7 @@ start_publisher() { local lang="$1" broadcast="$2" log="$TMP/pub-$1.log" case "$lang" in rust) - (ffmpeg_h264 | "$MOQ" --client-connect "$URL" --broadcast "$broadcast" import avc3) >"$log" 2>&1 & + (ffmpeg_h264 | "$MOQ" --connect "$URL" --broadcast "$broadcast" import avc3) >"$log" 2>&1 & ;; python) (ffmpeg_h264 | "$PY" "$CLIENTS/python/smoke.py" \ @@ -655,7 +655,7 @@ run_subscriber() { # moq only handles SIGINT, so -k forces SIGKILL if it ignores the # SIGTERM that fires when no data arrives within the timeout. local n - n=$(timeout -k 3 "$TIMEOUT" "$MOQ" --client-connect "$URL" --broadcast "$broadcast" \ + n=$(timeout -k 3 "$TIMEOUT" "$MOQ" --connect "$URL" --broadcast "$broadcast" \ export fmp4 2>/dev/null | head -c 1 | wc -c | tr -d ' ' || true) [[ "${n:-0}" -ge 1 ]] ;; diff --git a/smoke.toml b/smoke.toml index c84e237..00697b7 100644 --- a/smoke.toml +++ b/smoke.toml @@ -1,12 +1,13 @@ # Relay config for the cross-language interop smoke test. # Anonymous access, self-signed localhost cert, QUIC + WebSocket on 127.0.0.1:4443. +# Tracks moq-dev/moq main (post-#3793): [listen] bind + [auth] public patterns. [log] level = "info" -[server] +[listen] # QUIC on UDP. 127.0.0.1 avoids IPv6 flakiness on CI runners. -listen = "127.0.0.1:4443" +bind = "127.0.0.1:4443" tls.generate = ["localhost", "127.0.0.1"] [web.http] @@ -15,4 +16,4 @@ listen = "127.0.0.1:4443" [auth] # Allow anonymous access to everything. -public = "" +public = "**" diff --git a/token.sh b/token.sh index e1ff96b..6617515 100755 --- a/token.sh +++ b/token.sh @@ -119,6 +119,8 @@ have() { command -v "$1" >/dev/null 2>&1; } resolve_token() { # Prefer the renamed binary, but tolerate channels that still expose the old # executable during rollout. TOKEN_BIN remains authoritative when set. + # Post-#3793 the token CLI is `moq auth` (subcommand of the media CLI); + # TOKEN_SUBCMD carries that subcommand when TOKEN resolves to the `moq` binary. [[ -n "$TOKEN" ]] && return 0 if have moq-token; then TOKEN=moq-token @@ -129,6 +131,16 @@ resolve_token() { fi } +# Extra subcommand inserted between the Rust binary and its verb, e.g. `auth` +# for the post-#3793 `moq auth generate|sign|verify`. Empty for the standalone +# moq-token binaries. Derived from TOKEN (or TOKEN_BIN) once it is known. +token_subcmd() { + case "$(basename "${TOKEN%% *}")" in + moq) echo "auth" ;; + *) echo "" ;; + esac +} + # ── per-implementation adapters ────────────────────────────────────────────── # Each implementation's CLI differs (flag names, key encoding, verify output), # so every operation is funnelled through an adapter that normalises it. The @@ -149,7 +161,7 @@ cli_for() { # split is deliberate (runtime + path, or a whole `docker run ...` line), so # callers expand it unquoted. case "$1" in - rust) echo "$TOKEN" ;; + rust) echo "$TOKEN $(token_subcmd)" ;; # Mount TMP at its real path so the in-container CLI reads/writes the same # key/token files token.sh hands it. The image bundles the nix store, so # the binary's libiconv deps resolve (the brew bottle's bug doesn't apply). @@ -180,7 +192,18 @@ gen() { fi ;; js-node | js-bun) - if [[ "$algo" == HS* ]]; then + # Post-#3793 @moq/auth uses --out like the Rust CLI; the published + # @moq/token used --key. MOQ_SRC selects the source (new) shape. + if [[ -n "${MOQ_SRC:-}" ]]; then + if [[ "$algo" == HS* ]]; then + # shellcheck disable=SC2086 + $cli generate --out "$dir/sign.jwk" --algorithm "$algo" >/dev/null + cp "$dir/sign.jwk" "$dir/verify.jwk" + else + # shellcheck disable=SC2086 + $cli generate --out "$dir/sign.jwk" --algorithm "$algo" --public "$dir/verify.jwk" >/dev/null + fi + elif [[ "$algo" == HS* ]]; then # shellcheck disable=SC2086 $cli generate --key "$dir/sign.jwk" --algorithm "$algo" >/dev/null cp "$dir/sign.jwk" "$dir/verify.jwk" @@ -230,6 +253,13 @@ verify() { "$SMOKE_DIR/freshness.sh" || echo "WARN: freshness check failed (see above); continuing" >&2 resolve_token +rust_probe() { + # Probe the Rust token CLI once. Post-#3793 TOKEN may be `moq` with an + # `auth` subcommand, so the subcommand word-split is deliberate. + # shellcheck disable=SC2086,SC2046 + $TOKEN $(token_subcmd) generate --algorithm HS256 --out "$TMP/rust-probe.jwk" >"$TMP/rust-probe.log" 2>&1 +} + if needs rust; then if ! have "$TOKEN"; then mark_broken rust "$TOKEN not found (cargo/brew/apt/nix install moq-token-cli)" @@ -238,8 +268,9 @@ if needs rust; then # and aborts on launch) is exactly the packaging failure this test exists to # catch. A broken CLI marks the whole rust row unavailable instead of crashing # mid-matrix. - elif "$TOKEN" generate --algorithm HS256 --out "$TMP/rust-probe.jwk" >"$TMP/rust-probe.log" 2>&1; then - echo "rust: $(command -v "$TOKEN")" + elif rust_probe; then + sub=$(token_subcmd) + echo "rust: $(command -v "$TOKEN")${sub:+ $sub}" else mark_broken rust "$TOKEN on PATH but won't run (see below)" sed 's/^/ /' "$TMP/rust-probe.log" >&2 || true @@ -264,28 +295,51 @@ if needs rust-docker; then fi if needs js-node || needs js-bun; then - echo "installing js token client (@moq/token from npm)..." - if ! have bun; then - for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun not found (needed to install)"; done - elif (cd "$JS_DIR" && bun install) >"$TMP/js-install.log" 2>&1; then - # Resolve the published CLI path under each runtime we actually need. - if needs js-bun; then - if CLI_BUN=$(cd "$JS_DIR" && bun resolve-bin.mjs 2>"$TMP/js-bun-resolve.log"); then :; else - mark_broken js-bun "could not resolve @moq/token CLI under bun" - sed 's/^/ /' "$TMP/js-bun-resolve.log" >&2 || true + if [[ -n "${MOQ_SRC:-}" ]]; then + echo "installing js token client (@moq/auth from $MOQ_SRC/js)..." + if ! have bun; then + for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun not found (needed to install)"; done + elif (cd "$MOQ_SRC" && bun install) >"$TMP/js-install.log" 2>&1; then + # Drive the source CLI directly (TS source via bun/node); no bun install + # needed in a temp dir since workspace deps are already installed. + if needs js-bun; then + CLI_BUN="$MOQ_SRC/js/auth/src/cli.ts" fi - fi - if needs js-node; then - if ! have node; then - mark_broken js-node "node not found" - elif CLI_NODE=$(cd "$JS_DIR" && node resolve-bin.mjs 2>"$TMP/js-node-resolve.log"); then :; else - mark_broken js-node "could not resolve @moq/token CLI under node" - sed 's/^/ /' "$TMP/js-node-resolve.log" >&2 || true + if needs js-node; then + if ! have node; then + mark_broken js-node "node not found" + else + CLI_NODE="$MOQ_SRC/js/auth/src/cli.ts" + fi fi + else + for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun install in MOQ_SRC failed"; done + sed 's/^/ /' "$TMP/js-install.log" >&2 || true fi else - for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun install failed"; done - sed 's/^/ /' "$TMP/js-install.log" >&2 || true + echo "installing js token client (@moq/token from npm)..." + if ! have bun; then + for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun not found (needed to install)"; done + elif (cd "$JS_DIR" && bun install) >"$TMP/js-install.log" 2>&1; then + # Resolve the published CLI path under each runtime we actually need. + if needs js-bun; then + if CLI_BUN=$(cd "$JS_DIR" && bun resolve-bin.mjs 2>"$TMP/js-bun-resolve.log"); then :; else + mark_broken js-bun "could not resolve @moq/token CLI under bun" + sed 's/^/ /' "$TMP/js-bun-resolve.log" >&2 || true + fi + fi + if needs js-node; then + if ! have node; then + mark_broken js-node "node not found" + elif CLI_NODE=$(cd "$JS_DIR" && node resolve-bin.mjs 2>"$TMP/js-node-resolve.log"); then :; else + mark_broken js-node "could not resolve @moq/token CLI under node" + sed 's/^/ /' "$TMP/js-node-resolve.log" >&2 || true + fi + fi + else + for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun install failed"; done + sed 's/^/ /' "$TMP/js-install.log" >&2 || true + fi fi fi From a7d6a087501e9ced69c28e2ead05586b34198412 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 23 Sep 2026 09:55:53 -0700 Subject: [PATCH 2/3] Track moq main tip and migrate token interop to moq auth - dev.sh: drop MOQ_PIN/--pin; clone and test the tip of main (or --ref). - clients/dev: port to current @moq/net (Update.prefix, Origin.Requesting, branded Time.Milli); validate --timeout; hard-exit on timeout; fold the late Window replay and require it to converge on the retained bucket. - token.sh: rust cell is `moq auth` (TOKEN_BIN / MOQ_BIN / moq), rust-docker is moqdev/moq-cli, JS is published @moq/auth; drop the moq-token fallback and the MOQ_SRC source branch. - CI: stop installing moq-token-cli; nix no longer builds the removed flake attribute. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/smoke.yml | 24 ++--- README.md | 35 ++++--- clients/dev/lib.ts | 19 ++-- clients/dev/live.ts | 4 +- clients/dev/run.ts | 4 +- clients/dev/stats.ts | 24 +++-- clients/token/js/package.json | 4 +- clients/token/js/resolve-bin.mjs | 10 +- dev.sh | 21 +--- freshness.sh | 18 ++-- justfile | 12 +-- token.sh | 170 ++++++++----------------------- 12 files changed, 132 insertions(+), 213 deletions(-) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index d0bf19a..7a51abe 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -120,7 +120,7 @@ jobs: # Honor each published binary crate's Cargo.lock. Without --locked, the # cargo channel can select a newly broken transitive dependency even when # the crate was published and tested against a working resolution. - run: cargo install --locked moq-relay moq-cli moq-token-cli + run: cargo install --locked moq-relay moq-cli - name: Install moq Rust packages (apt) if: matrix.channel == 'apt' @@ -130,13 +130,13 @@ jobs: echo "deb [signed-by=/usr/share/keyrings/moq-keyring.gpg] https://apt.moq.dev stable main" \ | sudo tee /etc/apt/sources.list.d/moq.list sudo apt-get update - sudo apt-get install -y moq-relay moq-cli moq-token-cli + sudo apt-get install -y moq-relay moq-cli - name: Install moq Rust packages (brew) if: matrix.channel == 'brew' run: | brew tap moq-dev/tap - brew install moq-dev/tap/moq-relay moq-dev/tap/moq-cli moq-dev/tap/moq-token-cli + brew install moq-dev/tap/moq-relay moq-dev/tap/moq-cli - name: Install Nix (nix channel) if: matrix.channel == 'nix' @@ -153,11 +153,9 @@ jobs: run: | relay=$(nix build --refresh --no-link --print-out-paths 'github:moq-dev/moq#moq-relay') cli=$(nix build --refresh --no-link --print-out-paths 'github:moq-dev/moq#moq-cli') - token=$(nix build --refresh --no-link --print-out-paths 'github:moq-dev/moq#moq-token-cli') { echo "RELAY_BIN=$relay/bin/moq-relay" echo "MOQ_BIN=$cli/bin/moq" - echo "TOKEN_BIN=$token/bin/moq-token" } >> "$GITHUB_ENV" - name: Pull moqdev images (docker channel) @@ -214,17 +212,17 @@ jobs: # ── token interop ────────────────────────────────────────────────── # Independent of the media matrix: prove the published token tooling - # cross-verifies. moq-token rides the same channel as moq-relay/moq - # (cargo/apt/brew/nix, on PATH or TOKEN_BIN); @moq/token comes from npm and - # runs under both node and bun; rust-docker pulls the moqdev/moq-token-cli + # cross-verifies. `moq auth` rides the same channel as moq-relay/moq + # (cargo/apt/brew/nix, on PATH or MOQ_BIN); @moq/auth comes from npm and + # runs under both node and bun; rust-docker pulls the moqdev/moq-cli # image. The negative pass inside token.sh confirms each verifier rejects # tampered tokens and the wrong key. - name: Token interop run: | - # The `rust` impl needs moq-token on PATH, which only the - # cargo/apt/brew/nix channels install. The docker channel ships no such - # binary, so it exercises the Rust verifier through the - # moqdev/moq-token-cli image (rust-docker) instead of plain `rust`. + # The `rust` impl needs a native moq binary, which only the + # cargo/apt/brew/nix channels install. The docker channel's MOQ_BIN is a + # wrapper that can't see token.sh's temp files, so it exercises the + # Rust verifier through the moqdev/moq-cli image (rust-docker) instead. if [ "${{ matrix.channel }}" = "docker" ]; then impls="js-node,js-bun,rust-docker" else @@ -288,7 +286,7 @@ jobs: with: persist-credentials: false - - name: Checkout moq (main, pinned pre-publish revision) + - name: Checkout moq (main) uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: moq-dev/moq diff --git a/README.md b/README.md index a870ca5..3695a22 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Cross-language interop smoke test for the **public** [Media over QUIC](https://g The [moq-dev/moq](https://github.com/moq-dev/moq) monorepo has its own in-tree smoke test, but it builds every client from workspace source. That proves the code in the tree works; it does **not** prove a real user can install the published artifacts and have them talk to each other. A missing wheel, a stale Homebrew formula, a broken `.deb`, an export that didn't survive packaging, a Go module missing its header. none of that shows up until someone installs from a registry. -This repo installs each client straight from its public package registry, stands up a relay, and runs the interop matrix. A second **from-dev** channel (`./dev.sh`) installs the unpublished `main` surface from a moq checkout (path or git `main` at a pinned `MOQ_PIN` revision) and runs contract cases the published matrix cannot see yet: reconnecting `Connection` handles, announcements, credential refresh, publication replacement, catalog snapshots-then-deltas, and stats Snapshot versus Window. Embedded relay ownership stays in moq-relay; it is not a smoke client. +This repo installs each client straight from its public package registry, stands up a relay, and runs the interop matrix. A second **from-dev** channel (`./dev.sh`) installs the unpublished `main` surface from a moq checkout (path, or the tip of git `main`) and runs contract cases the published matrix cannot see yet: reconnecting `Connection` handles, announcements, credential refresh, publication replacement, catalog snapshots-then-deltas, and stats Snapshot versus Window. Embedded relay ownership stays in moq-relay; it is not a smoke client. - A relay (`moq-relay`) routes broadcasts. - For each publisher language, publish an H.264 broadcast. @@ -87,22 +87,22 @@ RELAY_BIN=/path/to/moq-relay MOQ_BIN=/path/to/moq ./smoke.sh # unpublished dev API (JS packages + relay from a moq checkout, not npm/crates.io): just dev --src /path/to/moq -just dev # clones github.com/moq-dev/moq (main, pinned via MOQ_PIN) +just dev # clones github.com/moq-dev/moq (main tip; --ref for another) ``` `smoke.sh` installs the language clients (PyPI / Go proxy / npm) into a scratch dir on each run, so you always test the latest published versions. It does **not** install the Rust binaries; that is the channel under test. -`dev.sh` is the other way around: it builds `moq-relay` from `MOQ_SRC` (or clones `main` at `MOQ_PIN`) and resolves `@moq/net`, `@moq/hang`, and `@moq/json` from that checkout's `js/` tree so the contract cases exercise the unpublished surface. `token.sh` accepts the same `MOQ_SRC` to drive the source `@moq/auth` CLI (which renamed `generate --key` to `--out`) against a source-built `moq auth` binary. +`dev.sh` is the other way around: it builds `moq-relay` from `MOQ_SRC` (or clones the tip of `main`) and resolves `@moq/net`, `@moq/hang`, and `@moq/json` from that checkout's `js/` tree so the contract cases exercise the unpublished surface. ## Layout ``` smoke.sh orchestrator: relay + media interop matrix (published packages) -dev.sh orchestrator: unpublished dev API contract cases (path/git `main`, pinned) +dev.sh orchestrator: unpublished dev API contract cases (path or git `main` tip) cloudflare.sh orchestrator: Cloudflare client through both projects' relays moxygen.sh orchestrator: moxygen protocol client through the moq-dev relay smoke.toml relay config (anonymous, self-signed localhost) -token.sh orchestrator: moq-token generate/verify interop matrix +token.sh orchestrator: `moq auth` generate/verify interop matrix clients/ python/smoke.py publish/subscribe via moq-rs (PyPI) go/ publish/subscribe via moq-dev/moq-go (go get) @@ -115,7 +115,7 @@ clients/ dev/ from-dev contract cases: Connection, catalog Snapshot, stats Snapshot vs Window (gst) subscribe via the moq-gst plugin (moqsrc); no client dir, driven by gst-launch docker/ moq-relay + moq wrappers: docker run the moqdev/* images (the docker channel) - token/js/ installs @moq/token (npm) for token.sh to drive under node + bun + token/js/ installs @moq/auth (npm) for token.sh to drive under node + bun cloudflare/ deterministic subgroup/datagram client using cloudflare/moq-rs Git HEAD freshness.sh enforces the "always latest, no package locks" policy .github/workflows/smoke.yml nightly + on-demand CI matrix (os x channel) @@ -131,15 +131,14 @@ published flavours, and this test proves they cross-verify: | Cell | Source under test | Install | |---|---|---| -| `rust` | the `moq-token` binary (crates.io / Homebrew tap / apt repo / the moq flake) | `cargo install moq-token-cli`, `brew install moq-dev/tap/moq-token-cli`, `apt install`, `nix run github:moq-dev/moq#moq-token-cli` | -| `js-node` | npm [`@moq/token`](https://www.npmjs.com/package/@moq/token)'s `moq-token` CLI, run under **node** | `npm i @moq/token` | -| `js-bun` | the same published npm package, run under **bun** | `npm i @moq/token` | -| `rust-docker` | the [`moqdev/moq-token-cli`](https://hub.docker.com/r/moqdev/moq-token-cli) Docker Hub image (`:latest`) | `docker run moqdev/moq-token-cli …` | - -Like `smoke.sh`, the Rust binary is taken from `PATH` (or `TOKEN_BIN`), preferring -`moq-token` and falling back to `moq-token-cli` while channels finish the rename; -`@moq/token` is installed from npm on each run; `rust-docker` `docker pull`s the -`moqdev/moq-token-cli` +| `rust` | `moq auth` from the moq CLI (crates.io / Homebrew tap / apt repo / the moq flake) | `cargo install moq-cli`, `brew install moq-dev/tap/moq-cli`, `apt install moq-cli`, `nix run github:moq-dev/moq#moq-cli` | +| `js-node` | npm [`@moq/auth`](https://www.npmjs.com/package/@moq/auth)'s `moq-auth` CLI, run under **node** | `npm i @moq/auth` | +| `js-bun` | the same published npm package, run under **bun** | `npm i @moq/auth` | +| `rust-docker` | `moq auth` in the [`moqdev/moq-cli`](https://hub.docker.com/r/moqdev/moq-cli) Docker Hub image (`:latest`) | `docker run moqdev/moq-cli auth …` | + +Like `smoke.sh`, the `moq` binary is taken from `PATH` (or `TOKEN_BIN` / `MOQ_BIN`); +`@moq/auth` is installed from npm on each run; `rust-docker` `docker pull`s the +`moqdev/moq-cli` image fresh (`:latest`) and runs the CLI in a throwaway container with the scratch dir bind-mounted. The image is built `FROM nixos/nix` and ships the nix store, so it's a genuinely different artifact from the `cargo`/`brew`/`apt` binaries — and @@ -149,7 +148,7 @@ daemon); set `TOKEN_DOCKER=podman` to drive it with podman. For every generator mints a key and signs a token, and the verifier checks it — covering both symmetric (`HS256`, shared secret) and asymmetric (`EdDSA`/`ES256`/`RS256`, sign-private/verify-public) keys, and the fact that one side's key encoding -(the Rust CLI writes base64url-JSON; `@moq/token` writes plain JSON) loads on the +(the Rust CLI writes base64url-JSON; `@moq/auth` writes plain JSON) loads on the other. A negative pass then confirms each verifier **rejects** a tampered token and a token signed by the wrong key, so a green cell means "accepts the valid one and refuses the bad ones", not "accepts everything". @@ -162,7 +161,7 @@ export that didn't survive `tsc`) shows up as a red cell. ```bash just token # default: rust generates + verifies (roundtrip + negatives) just token-full # full matrix: rust, js-node, js-bun + rust-docker (the - # moqdev/moq-token-cli image, where a container runtime is + # moqdev/moq-cli image, where a container runtime is # available; set TOKEN_DOCKER=podman to use podman) # or call it directly with explicit axes: ./token.sh --generators rust,js-node --verifiers rust,js-bun --algorithms HS256,EdDSA @@ -193,7 +192,7 @@ This test tracks the **latest published** packages, so it sometimes runs ahead o - **Native JS on node** (`js-native-node`): working. node briefly lagged bun here: `@moq/web-transport`'s `session.ts` did `import { NapiClient } from "../napi.js"` — a *named* import from a napi-rs CJS module whose exports node's ESM loader can't statically see, so node threw `does not provide an export named 'NapiClient'` while Bun's looser CJS interop accepted it. `@moq/web-transport` 0.1.2 shipped the predicted fix (default-import the now-`.cjs` binding, then destructure `NapiClient`), so this cell is green. Exactly the break-then-fix this repo exists to surface. - **Go (any role)**: working. The `moq-dev/moq-go` module was un-buildable (stuck at v0.2.15, missing the generated `moq.h` header and the prebuilt static libs, so `go get` + build failed); v0.2.22 now ships `moq.h` plus `libmoq_ffi.a` for linux (amd64/arm64), darwin, and windows, and a `CGO_ENABLED=1 go build` against it links cleanly — verified in a linux/amd64 container, clearing the blocker that kept this cell red. One caveat the matrix doesn't see: building the Go client on **macOS** still fails to link, because the module's darwin cgo `LDFLAGS` omit `-framework CoreServices` (needed by the bundled Rust `notify` crate's FSEvents backend); CI only builds Go on Linux. Tracked upstream in moq-dev/moq's `go/moq/cgo.go`. - **GStreamer subscribe** (`gst`): working. `moq-gst` ships apt/brew/rpm/tarball + nix artifacts, so the cell resolves the newest tag and selects the matching platform tarball from that release's asset metadata. The published plugin load-checks green — `gst-inspect-1.0 moq` exposes `moqsrc`/`moqsink` against a system GStreamer — and `moqsrc` reads a rust-published H.264 broadcast end-to-end. -- **Token interop** (`token.sh`): working on **cargo / apt / nix** plus the **`moqdev/moq-token-cli` Docker image** (Linux). The published `moq-token` binary (from crates.io / apt / nix / Docker Hub) and `@moq/token` (npm, under both node and bun) cross-verify every token across `HS256`, `EdDSA`, `ES256`, and `RS256`, and each verifier rejects tampered tokens and the wrong key. The Docker cell (`rust-docker`) proves the image — built `FROM nixos/nix`, so it carries the libiconv the brew bottle used to leak — runs cleanly. Subscriber-only languages don't ship token tooling yet, so the matrix is rust (binary + Docker) + the two JS runtimes for now. +- **Token interop** (`token.sh`): working on **cargo / apt / nix** plus the **`moqdev/moq-cli` Docker image** (Linux). The published `moq auth` subcommand (from crates.io / apt / nix / Docker Hub) and `@moq/auth` (npm, under both node and bun) cross-verify every token across `HS256`, `EdDSA`, `ES256`, and `RS256`, and each verifier rejects tampered tokens and the wrong key. The Docker cell (`rust-docker`) proves the image — built `FROM nixos/nix`, so it carries the libiconv the brew bottle used to leak — runs cleanly. Subscriber-only languages don't ship token tooling yet, so the matrix is rust (binary + Docker) + the two JS runtimes for now. - **Token interop on the Homebrew bottle** (`rust` cells, macOS `brew`): working. The `moq-dev/tap/moq-token-cli` package's `moq-token` binary used to abort on launch — it baked in a `/nix/store/…-libiconv/lib/libiconv.2.dylib` rpath from the build sandbox that doesn't exist on a user's Mac (`dyld: Library not loaded`). The 0.5.31 bottle fixes it: its only `LC_RPATH` is now `/usr/lib`, so `@rpath/libiconv.2.dylib` resolves to the system libiconv and the binary runs (verified locally — `generate --algorithm HS256` succeeds, no leaked `/nix/store` rpath). `token.sh` still probes the binary once at startup, so a relapse would be caught again. Exactly the break-then-fix this repo exists to surface. - **Cloudflare interoperability**: the Cloudflare client publishes and subscribes over WebTransport and raw QUIC through both `cloudflare/moq-rs`'s `moq-relay-ietf` and `moq-dev/moq`'s `moq-relay`, with sustained subgroup payloads checked byte-for-byte. Cloudflare's relay additionally exercises datagrams in both directions. This is a source-head smoke test, so a later upstream commit can intentionally turn it red. - **Moxygen interoperability**: currently **red**. Moxygen's published source-head interop client negotiates draft-16 and passes 5/6 relay scenarios through `moq-dev/moq`, but `announce-subscribe` closes the subscriber session instead of routing it to the announced publisher. The failure reproduces over WebTransport and raw QUIC with the published relay, and over WebTransport with current moq-dev HEAD. CI runs the full Linux/amd64 Docker lane as non-blocking diagnostic coverage until the mismatch is fixed; `just moxygen` still exits nonzero locally. diff --git a/clients/dev/lib.ts b/clients/dev/lib.ts index 0600f74..2407347 100644 --- a/clients/dev/lib.ts +++ b/clients/dev/lib.ts @@ -1,7 +1,7 @@ // Shared helpers for the from-dev API contract cases. import * as Moq from "@moq/net"; -export const REPLAY_MS = 30_000; +export const REPLAY_MS = Moq.Time.Milli(30_000); export function parseUrl(): { url: URL; timeoutMs: number } { const args = process.argv.slice(2); @@ -15,7 +15,12 @@ export function parseUrl(): { url: URL; timeoutMs: number } { console.error("usage: run.ts --url URL [--timeout S]"); process.exit(2); } - return { url: new URL(url), timeoutMs: Number.parseFloat(timeout) * 1000 }; + const seconds = Number(timeout); + if (!Number.isFinite(seconds) || seconds <= 0) { + console.error(`invalid --timeout: ${timeout}`); + process.exit(2); + } + return { url: new URL(url), timeoutMs: seconds * 1000 }; } export async function waitUntil(pred: () => boolean, label: string, ms = 10_000): Promise { @@ -39,9 +44,9 @@ export async function connected(conn: Moq.Connection, ms = 10_000): Promise { @@ -74,5 +79,5 @@ export function equal(a: unknown, b: unknown): boolean { export function handle(url: URL): Moq.Connection { // Private loops so a publisher and a subscriber do not share an origin and skip the relay. - return new Moq.Connection({ url, share: false, linger: 0 }); + return new Moq.Connection({ url, share: false, linger: Moq.Time.Milli(0) }); } diff --git a/clients/dev/live.ts b/clients/dev/live.ts index 70f13d8..33a1bd1 100644 --- a/clients/dev/live.ts +++ b/clients/dev/live.ts @@ -2,7 +2,7 @@ // Models the moq.pro live session (reconnecting handle, URL swap, announce cursor) // without copying that app. import * as Moq from "@moq/net"; -import { connected, handle, waitActive, waitAnnounce, waitUntil } from "./lib.ts"; +import { connected, handle, REPLAY_MS, waitActive, waitAnnounce, waitUntil } from "./lib.ts"; const PATH = "dev.live"; @@ -85,7 +85,7 @@ function publish(origin: Moq.Origin.Table, path: string, payload: string) { } async function expectFrame(broadcast: Moq.Broadcast.Consumer, payload: string): Promise { - const track = broadcast.track("messages").subscribe({ priority: 0, maxAge: 30_000 }); + const track = broadcast.track("messages").subscribe({ priority: 0, maxAge: REPLAY_MS }); try { const group = await track.recvGroup(); if (!group) throw new Error("track ended before a group arrived"); diff --git a/clients/dev/run.ts b/clients/dev/run.ts index ab6e7f5..c9ceb2f 100644 --- a/clients/dev/run.ts +++ b/clients/dev/run.ts @@ -28,7 +28,9 @@ try { ]); } catch (err) { console.error(`error: ${err instanceof Error ? err.message : String(err)}`); - process.exitCode = 1; + // Hard exit: Promise.race doesn't cancel the losing case, and its open + // connections would otherwise keep bun alive past the timeout. + process.exit(1); } finally { if (timeoutId !== undefined) clearTimeout(timeoutId); } diff --git a/clients/dev/stats.ts b/clients/dev/stats.ts index a583795..397fc4c 100644 --- a/clients/dev/stats.ts +++ b/clients/dev/stats.ts @@ -10,10 +10,6 @@ const PATH = "dev.stats"; type Snapshot = { bytes: number }; type Bucket = { start: number; bytes: number }; -function pushedBytes(event: Json.Window.Event | undefined): number | undefined { - return event && "push" in event ? event.push.value.bytes : undefined; -} - export async function stats(url: URL): Promise { const pub = handle(url); const sub = handle(url); @@ -79,10 +75,22 @@ export async function stats(url: URL): Promise { const lateRollup = new Json.Window.Consumer({ track: consumer.track("minute").subscribe({ priority: 0, maxAge: REPLAY_MS }), }); - const first = await lateRollup.next(); - const second = pushedBytes(first) === 20 ? first : await lateRollup.next(); - if (pushedBytes(first) !== 20 && pushedBytes(second) !== 20) { - throw new Error(`late window joiner never saw the retained bucket: ${JSON.stringify([first, second])}`); + // Ops are positional within a group, so a joiner may be replayed the popped + // bucket and then its pop. Fold the replay: the window it converges on must be + // exactly the retained 20-byte bucket, with the popped one gone. + const window = new Map(); + const seen: unknown[] = []; + while (!(window.size === 1 && [...window.values()][0] === 20)) { + const event = await lateRollup.next(); + seen.push(event); + if (!event || seen.length > 8) { + throw new Error(`late window joiner never converged on the retained bucket: ${JSON.stringify(seen)}`); + } + if ("push" in event) window.set(event.push.index, event.push.value.bytes); + else { + const span = "pop" in event ? event.pop : event.skip; + for (let i = span.start; i < span.end; i++) window.delete(i); + } } live.finish(); diff --git a/clients/token/js/package.json b/clients/token/js/package.json index 8206e37..57c62cc 100644 --- a/clients/token/js/package.json +++ b/clients/token/js/package.json @@ -2,8 +2,8 @@ "name": "moq-smoke-token", "private": true, "type": "module", - "description": "Installs the published @moq/token package so token.sh can drive its CLI under node and bun.", + "description": "Installs the published @moq/auth package so token.sh can drive its CLI under node and bun.", "dependencies": { - "@moq/token": "latest" + "@moq/auth": "latest" } } diff --git a/clients/token/js/resolve-bin.mjs b/clients/token/js/resolve-bin.mjs index c1fb114..abcf0c6 100644 --- a/clients/token/js/resolve-bin.mjs +++ b/clients/token/js/resolve-bin.mjs @@ -1,9 +1,9 @@ -// Print the absolute path to the published @moq/token CLI entrypoint. +// Print the absolute path to the published @moq/auth CLI entrypoint. // // token.sh runs this with BOTH node and bun so each runtime resolves the same // installed package and we drive the *published* bin (compiled dist), not the // in-tree TypeScript source. We read the installed package.json straight off -// disk rather than via module resolution: @moq/token's `exports` map doesn't +// disk rather than via module resolution: @moq/auth's `exports` map doesn't // expose ./package.json, which Node's strict ESM resolver refuses (bun allows // it), so require.resolve would work under bun but throw under node. Reading the // file keeps both runtimes on the same path, and the bin name is still taken @@ -11,12 +11,12 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -const pkgDir = resolve(process.cwd(), "node_modules/@moq/token"); +const pkgDir = resolve(process.cwd(), "node_modules/@moq/auth"); const pkg = JSON.parse(readFileSync(resolve(pkgDir, "package.json"), "utf8")); -const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["moq-token"]; +const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["moq-auth"]; if (!bin) { - console.error("@moq/token exposes no moq-token bin; published package changed shape"); + console.error("@moq/auth exposes no moq-auth bin; published package changed shape"); process.exit(1); } diff --git a/dev.sh b/dev.sh index 0268b0b..a1fdb14 100755 --- a/dev.sh +++ b/dev.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # Prove the unpublished API by installing JS packages from a moq checkout -# (path or git `main` at a pinned revision) and running contract cases a +# (path or a git ref, `main` by default) and running contract cases a # published-package matrix cannot see yet. The cargo/apt/brew/nix/docker media # matrix is unchanged: this is a second channel, not a replacement. # # MOQ_SRC=/path/to/moq ./dev.sh # ./dev.sh --src /path/to/moq -# ./dev.sh # clones github.com/moq-dev/moq (main, pinned) into a temp dir +# ./dev.sh # clones github.com/moq-dev/moq (main tip) into a temp dir +# ./dev.sh --ref # or another branch/tag set -euo pipefail SMOKE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -16,9 +17,6 @@ PORT="${SMOKE_PORT:-}" MOQ_SRC="${MOQ_SRC:-}" MOQ_GIT="${MOQ_GIT:-https://github.com/moq-dev/moq.git}" MOQ_REF="${MOQ_REF:-main}" -# Pinned pre-publish revision: the merge that introduced the breaking API -# surface under test. Override with --ref / MOQ_REF to move it forward. -MOQ_PIN="${MOQ_PIN:-5d0991b9991305be907e6c0682a4e276722eeed0}" require_value() { if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then @@ -44,11 +42,6 @@ while [[ $# -gt 0 ]]; do MOQ_REF="$2" shift 2 ;; - --pin) - require_value "$@" - MOQ_PIN="$2" - shift 2 - ;; --timeout) require_value "$@" TIMEOUT="$2" @@ -90,12 +83,9 @@ for t in bun cargo curl git pgrep; do done if [[ -z "$MOQ_SRC" ]]; then - echo "cloning $MOQ_GIT ($MOQ_REF @ ${MOQ_PIN:-HEAD})..." + echo "cloning $MOQ_GIT ($MOQ_REF)..." git clone --depth 1 --branch "$MOQ_REF" "$MOQ_GIT" "$TMP/moq" MOQ_SRC="$TMP/moq" - if [[ -n "${MOQ_PIN:-}" ]]; then - git -C "$MOQ_SRC" fetch --depth 1 origin "$MOQ_PIN" && git -C "$MOQ_SRC" checkout "$MOQ_PIN" - fi elif [[ ! -d "$MOQ_SRC" ]]; then echo "error: --src is not a directory: $MOQ_SRC" >&2 exit 1 @@ -105,9 +95,6 @@ MOQ_SRC=$(cd "$MOQ_SRC" && pwd) echo "moq: $MOQ_SRC" echo "revision: $(git -C "$MOQ_SRC" rev-parse HEAD)" echo "ref: $(git -C "$MOQ_SRC" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)" -if [[ -n "${MOQ_PIN:-}" ]] && [[ "$(git -C "$MOQ_SRC" rev-parse HEAD)" != "$MOQ_PIN" ]]; then - echo "warn: MOQ_SRC revision $(git -C "$MOQ_SRC" rev-parse --short HEAD) != pinned $MOQ_PIN (testing unpinned source)" >&2 -fi RELAY="${RELAY_BIN:-}" if [[ -z "$RELAY" ]]; then diff --git a/freshness.sh b/freshness.sh index deed4c0..74ba233 100755 --- a/freshness.sh +++ b/freshness.sh @@ -45,18 +45,18 @@ for dep in @moq/net @moq/hang @moq/web-transport; do fail=1 fi done -# The token client (@moq/token, driven by token.sh under node and bun) must be latest. -ver=$(grep -oE "\"@moq/token\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" clients/token/js/package.json | sed -E 's/.*"([^"]*)"$/\1/') -if [[ "$ver" == "latest" ]]; then note ok "@moq/token -> \"$ver\""; else - note FAIL "@moq/token pinned to \"$ver\" (want \"latest\")" +# The token client (@moq/auth, driven by token.sh under node and bun) must be latest. +ver=$(grep -oE "\"@moq/auth\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" clients/token/js/package.json | sed -E 's/.*"([^"]*)"$/\1/') +if [[ "$ver" == "latest" ]]; then note ok "@moq/auth -> \"$ver\""; else + note FAIL "@moq/auth pinned to \"$ver\" (want \"latest\")" fail=1 fi # The token Docker image must be the unpinned (:latest) tag, pulled fresh each run. # shellcheck disable=SC2016 # grepping for these literal strings in token.sh; the $vars must NOT expand here -if grep -qF 'DOCKER_TOKEN_IMAGE:-moqdev/moq-token-cli}' token.sh && grep -qF '"$DOCKER" pull "$DOCKER_TOKEN_IMAGE"' token.sh; then - note ok "moqdev/moq-token-cli -> :latest (pulled each run)" +if grep -qF 'DOCKER_TOKEN_IMAGE:-moqdev/moq-cli}' token.sh && grep -qF '"$DOCKER" pull "$DOCKER_TOKEN_IMAGE"' token.sh; then + note ok "moqdev/moq-cli (token) -> :latest (pulled each run)" else - note FAIL "token.sh no longer pulls an unpinned moqdev/moq-token-cli :latest" + note FAIL "token.sh no longer pulls an unpinned moqdev/moq-cli :latest" fail=1 fi # The media Docker channel (relay + cli wrappers) must use the unpinned (:latest) @@ -142,8 +142,8 @@ fi # From-dev consumes a moq checkout, not npm/crates.io latest. The published # matrix above stays on latest; this channel is what proves unpublished `main`. # shellcheck disable=SC2016 -if grep -q 'ln -sfn "$MOQ_SRC/js/$pkg"' dev.sh && grep -q 'MOQ_REF:-main}' dev.sh && grep -q 'MOQ_PIN:-' dev.sh; then - note ok "from-dev JS packages -> MOQ_SRC/js (git ref default: main, pinned)" +if grep -q 'ln -sfn "$MOQ_SRC/js/$pkg"' dev.sh && grep -q 'MOQ_REF:-main}' dev.sh && ! grep -q 'MOQ_PIN' dev.sh; then + note ok "from-dev JS packages -> MOQ_SRC/js (git ref default: main tip, unpinned)" else note FAIL "dev.sh no longer installs unpublished JS packages from a moq checkout" fail=1 diff --git a/justfile b/justfile index 58d647a..da8c306 100644 --- a/justfile +++ b/justfile @@ -30,15 +30,15 @@ cloudflare: moxygen: ./moxygen.sh -# Token interop: install moq-token in each published flavour and cross-verify. -# The Rust moq-token binary comes from a channel (PATH); @moq/token comes from npm, +# Token interop: install `moq auth` in each published flavour and cross-verify. +# The Rust `moq auth` subcommand comes from a channel (PATH); @moq/auth comes from npm, # driven under both node and bun. Default: rust only. Pass flags through, e.g. # just token --generators rust,js-node --verifiers rust,js-bun --algorithms HS256 token *args: ./token.sh {{ args }} # Full token matrix: every implementation mints and verifies every other's -# tokens. rust-docker pulls the published moqdev/moq-token-cli image (needs a +# tokens. rust-docker pulls the published moqdev/moq-cli image (needs a # container runtime; set TOKEN_DOCKER=podman to use podman instead of docker). token-full: ./token.sh --generators rust,js-node,js-bun,rust-docker --verifiers rust,js-node,js-bun,rust-docker @@ -72,11 +72,11 @@ nix-channel *args: negative *args: ./smoke.sh --negative {{ args }} -# Unpublished API: install JS packages from a moq checkout (path or git -# `main` at a pinned revision) and run the contract cases. The +# Unpublished API: install JS packages from a moq checkout (path, or git +# `main` tip by default) and run the contract cases. The # published-package matrix is unchanged. # just dev --src /path/to/moq -# just dev # clones github.com/moq-dev/moq (main, pinned) +# just dev # clones github.com/moq-dev/moq (main tip) dev *args: ./dev.sh {{ args }} diff --git a/token.sh b/token.sh index 6617515..6cfa5a1 100755 --- a/token.sh +++ b/token.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # Cross-implementation token interop smoke test against the PUBLIC packages. # -# moq-relay authenticates with JWTs minted by the moq-token tooling, which ships +# moq-relay authenticates with JWTs minted by the moq auth tooling, which ships # in several flavours from several registries: # -# - rust : the moq-token binary (cargo / brew / apt / nix), on PATH -# - js-node : the @moq/token npm package's `moq-token` CLI, run under node +# - rust : `moq auth` from the moq CLI (cargo / brew / apt / nix), on PATH +# - js-node : the @moq/auth npm package's `moq-auth` CLI, run under node # - js-bun : the same published npm package, run under bun # # A token minted by any one of these must verify under every other one, or a @@ -33,13 +33,14 @@ VERIFIERS="rust" # can't silently stop exercising one. Override with --algorithms / TOKEN_ALGORITHMS. ALGORITHMS="${TOKEN_ALGORITHMS:-HS256,EdDSA,ES256,RS256}" -# The Rust CLI under test. Whatever channel installed it (cargo/brew/apt/nix) -# just has to leave it on PATH; override here to point at a specific build. -TOKEN="${TOKEN_BIN:-}" +# The moq CLI under test (its `auth` subcommand). Whatever channel installed it +# (cargo/brew/apt/nix) just has to leave it on PATH; TOKEN_BIN (or MOQ_BIN, shared +# with smoke.sh) points at a specific build. +TOKEN="${TOKEN_BIN:-${MOQ_BIN:-moq}}" # The published Docker image for the `rust-docker` cell. Untagged = :latest, the # tag the release pipeline moves to the newest version; pulled fresh each run. -DOCKER_TOKEN_IMAGE="${DOCKER_TOKEN_IMAGE:-moqdev/moq-token-cli}" +DOCKER_TOKEN_IMAGE="${DOCKER_TOKEN_IMAGE:-moqdev/moq-cli}" # Container runtime for that cell. `docker` by default (what GitHub's Linux # runners ship); set TOKEN_DOCKER=podman to use a drop-in-compatible one. DOCKER="${TOKEN_DOCKER:-docker}" @@ -92,8 +93,8 @@ needs() { } TMP=$(mktemp -d) -CLI_NODE="" # node + @moq/token CLI path (set in prepare) -CLI_BUN="" # bun + @moq/token CLI path (set in prepare) +CLI_NODE="" # node + @moq/auth CLI path (set in prepare) +CLI_BUN="" # bun + @moq/auth CLI path (set in prepare) BROKEN_IMPLS="" mark_broken() { @@ -116,31 +117,6 @@ trap cleanup EXIT have() { command -v "$1" >/dev/null 2>&1; } -resolve_token() { - # Prefer the renamed binary, but tolerate channels that still expose the old - # executable during rollout. TOKEN_BIN remains authoritative when set. - # Post-#3793 the token CLI is `moq auth` (subcommand of the media CLI); - # TOKEN_SUBCMD carries that subcommand when TOKEN resolves to the `moq` binary. - [[ -n "$TOKEN" ]] && return 0 - if have moq-token; then - TOKEN=moq-token - elif have moq-token-cli; then - TOKEN=moq-token-cli - else - TOKEN=moq-token - fi -} - -# Extra subcommand inserted between the Rust binary and its verb, e.g. `auth` -# for the post-#3793 `moq auth generate|sign|verify`. Empty for the standalone -# moq-token binaries. Derived from TOKEN (or TOKEN_BIN) once it is known. -token_subcmd() { - case "$(basename "${TOKEN%% *}")" in - moq) echo "auth" ;; - *) echo "" ;; - esac -} - # ── per-implementation adapters ────────────────────────────────────────────── # Each implementation's CLI differs (flag names, key encoding, verify output), # so every operation is funnelled through an adapter that normalises it. The @@ -154,18 +130,18 @@ token_subcmd() { # Symmetric (HS256): sign.jwk == verify.jwk (shared secret). # Asymmetric (EdDSA/ES256/RS256): verify.jwk is the public half. # Key encodings cross over fine: the Rust CLI writes base64url-JSON and reads -# either; @moq/token writes plain JSON and reads either. +# either; @moq/auth writes plain JSON and reads either. cli_for() { - # The command prefix that runs each implementation's moq-token CLI. The word + # The command prefix that runs each implementation's auth CLI. The word # split is deliberate (runtime + path, or a whole `docker run ...` line), so # callers expand it unquoted. case "$1" in - rust) echo "$TOKEN $(token_subcmd)" ;; + rust) echo "$TOKEN auth" ;; # Mount TMP at its real path so the in-container CLI reads/writes the same # key/token files token.sh hands it. The image bundles the nix store, so # the binary's libiconv deps resolve (the brew bottle's bug doesn't apply). - rust-docker) echo "$DOCKER run --rm --user $(id -u):$(id -g) -v $TMP:$TMP -w $TMP $DOCKER_TOKEN_IMAGE" ;; + rust-docker) echo "$DOCKER run --rm --user $(id -u):$(id -g) -v $TMP:$TMP -w $TMP $DOCKER_TOKEN_IMAGE auth" ;; js-node) echo "node $CLI_NODE" ;; js-bun) echo "bun $CLI_BUN" ;; *) return 1 ;; @@ -180,39 +156,15 @@ gen() { echo "unknown generator: $impl" >&2 return 1 } - case "$impl" in - rust | rust-docker) - if [[ "$algo" == HS* ]]; then - # shellcheck disable=SC2086 # cli is a deliberate multi-word prefix - $cli generate --algorithm "$algo" --out "$dir/sign.jwk" - cp "$dir/sign.jwk" "$dir/verify.jwk" - else - # shellcheck disable=SC2086 - $cli generate --algorithm "$algo" --out "$dir/sign.jwk" --public "$dir/verify.jwk" - fi - ;; - js-node | js-bun) - # Post-#3793 @moq/auth uses --out like the Rust CLI; the published - # @moq/token used --key. MOQ_SRC selects the source (new) shape. - if [[ -n "${MOQ_SRC:-}" ]]; then - if [[ "$algo" == HS* ]]; then - # shellcheck disable=SC2086 - $cli generate --out "$dir/sign.jwk" --algorithm "$algo" >/dev/null - cp "$dir/sign.jwk" "$dir/verify.jwk" - else - # shellcheck disable=SC2086 - $cli generate --out "$dir/sign.jwk" --algorithm "$algo" --public "$dir/verify.jwk" >/dev/null - fi - elif [[ "$algo" == HS* ]]; then - # shellcheck disable=SC2086 - $cli generate --key "$dir/sign.jwk" --algorithm "$algo" >/dev/null - cp "$dir/sign.jwk" "$dir/verify.jwk" - else - # shellcheck disable=SC2086 - $cli generate --key "$dir/sign.jwk" --algorithm "$algo" --public "$dir/verify.jwk" >/dev/null - fi - ;; - esac + # Every CLI shares the same generate flags; JS also echoes the key to stdout. + if [[ "$algo" == HS* ]]; then + # shellcheck disable=SC2086 # cli is a deliberate multi-word prefix + $cli generate --algorithm "$algo" --out "$dir/sign.jwk" >/dev/null + cp "$dir/sign.jwk" "$dir/verify.jwk" + else + # shellcheck disable=SC2086 + $cli generate --algorithm "$algo" --out "$dir/sign.jwk" --public "$dir/verify.jwk" >/dev/null + fi } sign() { @@ -251,26 +203,17 @@ verify() { # ── setup ──────────────────────────────────────────────────────────────────── "$SMOKE_DIR/freshness.sh" || echo "WARN: freshness check failed (see above); continuing" >&2 -resolve_token - -rust_probe() { - # Probe the Rust token CLI once. Post-#3793 TOKEN may be `moq` with an - # `auth` subcommand, so the subcommand word-split is deliberate. - # shellcheck disable=SC2086,SC2046 - $TOKEN $(token_subcmd) generate --algorithm HS256 --out "$TMP/rust-probe.jwk" >"$TMP/rust-probe.log" 2>&1 -} if needs rust; then if ! have "$TOKEN"; then - mark_broken rust "$TOKEN not found (cargo/brew/apt/nix install moq-token-cli)" + mark_broken rust "$TOKEN not found (cargo/brew/apt/nix install moq-cli)" # `have` only checks the file exists; actually run it once, since a broken # published binary (e.g. a Homebrew bottle that baked in a /nix/store rpath # and aborts on launch) is exactly the packaging failure this test exists to # catch. A broken CLI marks the whole rust row unavailable instead of crashing # mid-matrix. - elif rust_probe; then - sub=$(token_subcmd) - echo "rust: $(command -v "$TOKEN")${sub:+ $sub}" + elif "$TOKEN" auth generate --algorithm HS256 --out "$TMP/rust-probe.jwk" >"$TMP/rust-probe.log" 2>&1; then + echo "rust: $(command -v "$TOKEN") auth" else mark_broken rust "$TOKEN on PATH but won't run (see below)" sed 's/^/ /' "$TMP/rust-probe.log" >&2 || true @@ -286,7 +229,7 @@ if needs rust-docker; then # always-latest install), then run it once to confirm the image works. elif "$DOCKER" pull "$DOCKER_TOKEN_IMAGE" >"$TMP/docker-pull.log" 2>&1 && "$DOCKER" run --rm --user "$(id -u):$(id -g)" -v "$TMP:$TMP" -w "$TMP" \ - "$DOCKER_TOKEN_IMAGE" generate --algorithm HS256 --out "$TMP/docker-probe.jwk" >"$TMP/docker-probe.log" 2>&1; then + "$DOCKER_TOKEN_IMAGE" auth generate --algorithm HS256 --out "$TMP/docker-probe.jwk" >"$TMP/docker-probe.log" 2>&1; then echo "rust-docker: $DOCKER_TOKEN_IMAGE (latest, via $DOCKER)" else mark_broken rust-docker "$DOCKER pull/run $DOCKER_TOKEN_IMAGE failed (see below)" @@ -295,51 +238,28 @@ if needs rust-docker; then fi if needs js-node || needs js-bun; then - if [[ -n "${MOQ_SRC:-}" ]]; then - echo "installing js token client (@moq/auth from $MOQ_SRC/js)..." - if ! have bun; then - for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun not found (needed to install)"; done - elif (cd "$MOQ_SRC" && bun install) >"$TMP/js-install.log" 2>&1; then - # Drive the source CLI directly (TS source via bun/node); no bun install - # needed in a temp dir since workspace deps are already installed. - if needs js-bun; then - CLI_BUN="$MOQ_SRC/js/auth/src/cli.ts" - fi - if needs js-node; then - if ! have node; then - mark_broken js-node "node not found" - else - CLI_NODE="$MOQ_SRC/js/auth/src/cli.ts" - fi + echo "installing js token client (@moq/auth from npm)..." + if ! have bun; then + for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun not found (needed to install)"; done + elif (cd "$JS_DIR" && bun install) >"$TMP/js-install.log" 2>&1; then + # Resolve the published CLI path under each runtime we actually need. + if needs js-bun; then + if CLI_BUN=$(cd "$JS_DIR" && bun resolve-bin.mjs 2>"$TMP/js-bun-resolve.log"); then :; else + mark_broken js-bun "could not resolve @moq/auth CLI under bun" + sed 's/^/ /' "$TMP/js-bun-resolve.log" >&2 || true fi - else - for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun install in MOQ_SRC failed"; done - sed 's/^/ /' "$TMP/js-install.log" >&2 || true fi - else - echo "installing js token client (@moq/token from npm)..." - if ! have bun; then - for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun not found (needed to install)"; done - elif (cd "$JS_DIR" && bun install) >"$TMP/js-install.log" 2>&1; then - # Resolve the published CLI path under each runtime we actually need. - if needs js-bun; then - if CLI_BUN=$(cd "$JS_DIR" && bun resolve-bin.mjs 2>"$TMP/js-bun-resolve.log"); then :; else - mark_broken js-bun "could not resolve @moq/token CLI under bun" - sed 's/^/ /' "$TMP/js-bun-resolve.log" >&2 || true - fi - fi - if needs js-node; then - if ! have node; then - mark_broken js-node "node not found" - elif CLI_NODE=$(cd "$JS_DIR" && node resolve-bin.mjs 2>"$TMP/js-node-resolve.log"); then :; else - mark_broken js-node "could not resolve @moq/token CLI under node" - sed 's/^/ /' "$TMP/js-node-resolve.log" >&2 || true - fi + if needs js-node; then + if ! have node; then + mark_broken js-node "node not found" + elif CLI_NODE=$(cd "$JS_DIR" && node resolve-bin.mjs 2>"$TMP/js-node-resolve.log"); then :; else + mark_broken js-node "could not resolve @moq/auth CLI under node" + sed 's/^/ /' "$TMP/js-node-resolve.log" >&2 || true fi - else - for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun install failed"; done - sed 's/^/ /' "$TMP/js-install.log" >&2 || true fi + else + for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun install failed"; done + sed 's/^/ /' "$TMP/js-install.log" >&2 || true fi fi From da0ef9160aff0accc48675c3ba8ed7172cb92651 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 23 Sep 2026 11:12:53 -0700 Subject: [PATCH 3/3] Use renamed moq-relay listen/auth flags in cloudflare.sh and moxygen.sh --server-bind/--tls-cert/--tls-key became --listen/--listen-tls-cert/ --listen-tls-key, and an empty --auth-public no longer grants everything. Co-Authored-By: Claude Opus 5.5 --- cloudflare.sh | 6 +++--- moxygen.sh | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cloudflare.sh b/cloudflare.sh index 1e5bff5..23a3d6e 100755 --- a/cloudflare.sh +++ b/cloudflare.sh @@ -136,11 +136,11 @@ start_relay() { ready="$HTTPS_URL/fingerprint" ;; moq-dev) - "$MOQ_RELAY" --server-bind "127.0.0.1:${PORT}" \ - --tls-cert "$TMP/localhost.crt" --tls-key "$TMP/localhost.key" \ + "$MOQ_RELAY" --listen "127.0.0.1:${PORT}" \ + --listen-tls-cert "$TMP/localhost.crt" --listen-tls-key "$TMP/localhost.key" \ --web-https-listen "127.0.0.1:${PORT}" \ --web-https-cert "$TMP/localhost.crt" --web-https-key "$TMP/localhost.key" \ - --auth-public "" >"$TMP/relay-moq-dev.log" 2>&1 & + --auth-public "**" >"$TMP/relay-moq-dev.log" 2>&1 & ready="$HTTPS_URL/certificate.sha256" ;; *) diff --git a/moxygen.sh b/moxygen.sh index 7a28b71..bbfda9e 100755 --- a/moxygen.sh +++ b/moxygen.sh @@ -91,11 +91,11 @@ if ! openssl req -x509 -newkey rsa:2048 -nodes \ fi echo "starting moq-dev relay on 127.0.0.1:${PORT}..." -"$MOQ_RELAY" --server-bind "127.0.0.1:${PORT}" \ - --tls-cert "$TMP/localhost.crt" --tls-key "$TMP/localhost.key" \ +"$MOQ_RELAY" --listen "127.0.0.1:${PORT}" \ + --listen-tls-cert "$TMP/localhost.crt" --listen-tls-key "$TMP/localhost.key" \ --web-https-listen "127.0.0.1:${PORT}" \ --web-https-cert "$TMP/localhost.crt" --web-https-key "$TMP/localhost.key" \ - --auth-public "" >"$TMP/relay.log" 2>&1 & + --auth-public "**" >"$TMP/relay.log" 2>&1 & RELAY_PID=$! ready=0