Some checks failed
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 33s
WAI operation catalogue / does this change touch it (push) Successful in 28s
wai-app and Create / wai-app · does this change touch it (push) Successful in 29s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 30s
WAI Studio / does this change touch it (push) Successful in 16s
wai-sink-swift native sink package / does this change touch it (push) Successful in 1m32s
wai-quantum studio and desktop / does this change touch it (push) Successful in 1m34s
wai-fleet service / does this change touch it (push) Successful in 1m40s
wai-audit collector and player integration / does this change touch it (push) Successful in 1m39s
wai-ledger service / does this change touch it (push) Successful in 1m37s
wai-quantum for JavaScript / does this change touch it (push) Successful in 1m34s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 1m41s
wai-sink-android native sink library / does this change touch it (push) Successful in 1m35s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 2m11s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 4m29s
joule-code conformance gate / joule-moq-relay, every target (push) Successful in 7m32s
joule-code conformance gate / joule-moq-ingest, every target (push) Successful in 9m9s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 6m1s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 8m1s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 11s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 45s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m22s
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Successful in 2m54s
WAI operation catalogue / the catalogue and every door · linux (push) Failing after 2m27s
wai-audit collector and player integration / collector · linux (push) Successful in 2m23s
wai-ledger service / service · linux (push) Successful in 3m55s
wai-fleet service / service · linux (push) Successful in 4m31s
wai-quantum for JavaScript / javascript · linux (push) Successful in 6m17s
wai-quantum studio and desktop / studio · browser (push) Successful in 7m43s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 10m31s
WAI Studio / Studio in WebKit · linux (push) Successful in 22m31s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Failing after 1m31s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 3m35s
wai release binaries build on Windows / the seven release binaries · windows (push) Successful in 18m5s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 10m46s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Successful in 1m3s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 56s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 7m44s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 9m17s
wai-fleet service / service · macos (push) Successful in 1m4s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 4m41s
wai-ledger service / service · macos (push) Successful in 1m7s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 2m35s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 7m17s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 15m0s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 15m50s
SETUP order. draft-ietf-moq-privacy-pass-auth-04 §3.5.1 has a relay that authenticates SETUP process the client's SETUP before it sends its own; WAI age-assurance §5.1 and live-rooms §10.5.2 had the relay send first. The decision was to follow -04, and the texts, the relay, the clients and the corpora do. The relay's SETUP used to carry the challenge the client's SETUP then proved its grant over, which the new order cannot do in one session. It is now -04's own flow (§3.5.6.1, Figure 4), and live-rooms §10.5.3's over HTTP: every SETUP a relay sends carries a fresh GRANT_CHALLENGE, good for the proofs of one later SETUP at that relay for limits.grant_challenge_ms (60 s), kept by the relay (src/challenge.rs) and consumed by the SETUP it admits. A client that holds none presents its grant unproved, is refused pop_missing with a challenge, and proves on a second session; one that holds the challenge of the relay's last SETUP to it proves on its first. A refused SETUP is answered with the relay's SETUP carrying a new Setup Option, GRANT_REFUSED (0x3A53, the reason's code), and the session is closed UNAUTHORIZED once the client has left or limits.setup_refusal_wait_ms (2 s) has passed: a close carries nothing sent before it, which -04 notes of its own challenge, so the refusal is said in the SETUP and the client that reads it leaves at once. A consumed challenge is pop_replayed; one never issued, another relay's, or too old, pop_wrong_challenge. The clients: joule_moq_relay::client (Client::open, connect_proving, challenge; its SETUP always first), the room service's clock and participants, the remote host agent, relay-to-relay links (a link's first SETUP is refused relay_id_unproved and it dials again at once with its proof), and the browser (MoqSession.connect's redial, SetupRefused). The interop client and the ingest gateway already sent first and present no grant. The order is the same at every draft; grants are proved at -23, -22, -18 and -16 in the relay's test. Not interoperable with the order before where a grant is presented: a client that waits for the relay's SETUP is sent none, and relays that peer change together. Sessions that present no grant are unaffected. What the draft-23 package left: - A -23 session's subgroup streams and datagrams are read by -23's object rules (a supported property outside its scope or repeated makes the track malformed; a grease value in the Mandatory Track Property range is no Mandatory Track Property); a -22 session keeps -22's reading. - Relays peer at -23: RELAY_PATH in the -23 codec and its bridge, a relay id taken at -23, links offering -23 and -22. An unnegotiated path closes a -23 session INVALID_PARAMETER. The -23 corpus gains 6 vectors (125, 70 negative) and moqt23_check.py reads the extension. - A grant that expires ends its subscriptions, each with PUBLISH_DONE EXPIRED_AUTH_TOKEN, and not the session, at -23; a -23 session that publishes by the grant, and every session at the older drafts, is closed as before. - LARGEST_OBJECT against -23 §7.5: confirmed but for one source, a publisher's PUBLISH_STATE_NOTIFY, which is taken now; held by a vector of six steps. Kept and stated: an object counts once whole, not from its first byte. Tests, each against planted faults: grant_proof (the relay sends nothing before the client's SETUP; the second session; seven refusals, each with its reason), room_authority (expiry at -23 beside -22), draft23 (the object rules against the same objects at -22; the LARGEST_OBJECT vector), cascade and control_sessions (a link at -23, opened on its second dial; the path at -23), the browser session's unit tests. Falsified: twelve of the changes reverted in turn, each failing a test. live-rooms §10.5.2, §10.5.4, §10.7 and §13, and age-assurance §5.1 and §5.5, say the order and what a challenge is bound to now. The rooms corpus renames one proof vector (acknowledged in the change note); the registry lists GRANT_REFUSED. Co-Authored-By: Claude <[email protected]>
407 lines
22 KiB
Rust
407 lines
22 KiB
Rust
//! A room with co-performance (WAI co-performance §8) kept by the service, against a relay on
|
|
//! loopback that holds the floor and the audience's inputs:
|
|
//!
|
|
//! - a lead's schedule that marks, asked for over HTTP with the lead's grant and its proof of
|
|
//! possession, signed by the clock; a request without a proof, with a proof used before, and
|
|
//! from a grant without the direct power, refused;
|
|
//! - two performers trading turns: an object in turn, one out of turn and one later than its
|
|
//! budget, each of the last two marked by the clock as it takes them;
|
|
//! - an AI participant's intents: one played as announced, one not;
|
|
//! - two audience members, recorded joining by the clock, voting in a window a lead opens; the
|
|
//! clock commits to the window once it closes and signs its selection, credited;
|
|
//! - a fidelity statement asked for by the lead;
|
|
//!
|
|
//! and the room as the relay serves it, with the clock's log of objects, verifies as
|
|
//! co-performance §9 verifies a room: accept, every object's marks founded, no object out of
|
|
//! turn or late without its mark.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use joule_moq::draft22::data::{FetchEntry, SubgroupHeader, SubgroupId, SubgroupObject};
|
|
use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties};
|
|
use joule_moq::draft22::Parameters;
|
|
use joule_moq::grant_proof::{self as gp, GrantProof};
|
|
use joule_moq::{TrackName, TrackNamespace};
|
|
use joule_moq_relay::client::{Client, ClientOptions};
|
|
use joule_moq_relay::config::{Authority, FloorConfig, Grant};
|
|
use joule_moq_relay::transport::mux::MuxConfig;
|
|
use joule_moq_relay::transport::websocket;
|
|
use joule_moq_relay::{RelayConfig, RelayServer};
|
|
use joule_moq_room::keys::Keys;
|
|
use joule_moq_room::{http, Config, Service};
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::BTreeMap;
|
|
use std::sync::atomic::Ordering;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::time::timeout;
|
|
use wai::co_performance::{self as co, Statement};
|
|
use wai::live_events::{kind, Caption, Event, Payload};
|
|
|
|
const STEP: Duration = Duration::from_secs(20);
|
|
const S: u64 = 1_000_000;
|
|
|
|
async fn relay(keys: &Keys) -> RelayServer {
|
|
let mut c = RelayConfig::loopback();
|
|
c.quic_listen = None;
|
|
c.auth.required = true;
|
|
c.auth.authorities = vec![Authority { key: hex::encode(keys.authority.verifying_key().to_bytes()), rooms: vec![] }];
|
|
let t = hex::encode(Sha256::digest(keys.clock_token));
|
|
let clock = hex::encode(keys.clock.verifying_key().to_bytes());
|
|
c.auth.grants = vec![
|
|
Grant { prefix: vec!["*".into(), clock.clone(), "events".into()], token_sha256: t.clone(), publish: true, subscribe: false, proof: false, unlimited: true, seek: false },
|
|
Grant { prefix: vec![], token_sha256: t, publish: false, subscribe: true, proof: false, unlimited: false, seek: true },
|
|
];
|
|
c.floor = Some(FloorConfig { room_keys: vec![clock], ..FloorConfig::default() });
|
|
RelayServer::start(c).await.expect("the relay starts")
|
|
}
|
|
|
|
/// One HTTP/1.1 request: its status, its headers (lowercase names) and its JSON.
|
|
async fn call(addr: std::net::SocketAddr, method: &str, path: &str, body: Option<&Value>, headers: &[(&str, String)]) -> (u16, BTreeMap<String, String>, Value) {
|
|
let mut s = tokio::net::TcpStream::connect(addr).await.unwrap();
|
|
let b = body.map(|v| v.to_string()).unwrap_or_default();
|
|
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: room.test\r\nCF-Connecting-IP: 10.7.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", b.len());
|
|
for (k, v) in headers {
|
|
req.push_str(&format!("{k}: {v}\r\n"));
|
|
}
|
|
req.push_str("\r\n");
|
|
req.push_str(&b);
|
|
s.write_all(req.as_bytes()).await.unwrap();
|
|
let mut out = Vec::new();
|
|
timeout(STEP, s.read_to_end(&mut out)).await.expect("an answer in time").unwrap();
|
|
let text = String::from_utf8(out).unwrap();
|
|
let (head, body) = text.split_once("\r\n\r\n").expect("a response");
|
|
let status: u16 = head.split(' ').nth(1).unwrap().parse().unwrap();
|
|
let hs = head.lines().skip(1).filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())).collect();
|
|
(status, hs, serde_json::from_str(body).unwrap_or(Value::Null))
|
|
}
|
|
|
|
fn key(i: u8) -> SigningKey {
|
|
SigningKey::from_bytes(&[i; 32])
|
|
}
|
|
|
|
fn pk(k: &SigningKey) -> [u8; 32] {
|
|
k.verifying_key().to_bytes()
|
|
}
|
|
|
|
fn pubhex(k: &SigningKey) -> String {
|
|
hex::encode(pk(k))
|
|
}
|
|
|
|
/// A page's identity in the room: its key and its grant.
|
|
struct Who {
|
|
key: SigningKey,
|
|
grant: Vec<u8>,
|
|
challenge: Option<[u8; 32]>,
|
|
}
|
|
|
|
impl Who {
|
|
/// A co-performance request with this grant and a proof over the last challenge the
|
|
/// service gave it, asking for one first when it holds none.
|
|
async fn ask(&mut self, addr: std::net::SocketAddr, path: &str, body: &Value) -> (u16, Value) {
|
|
for _ in 0..2 {
|
|
let mut headers = vec![("Authorization", format!("Bearer {}", gp::base64url(&self.grant)))];
|
|
if let Some(c) = self.challenge.take() {
|
|
let (digest, _) = gp::grant_digest_and_principal(&self.grant).unwrap();
|
|
let p = GrantProof::sign(digest, &self.key, c, &gp::context("POST", "room.test", path)).unwrap();
|
|
headers.push((gp::HEADER_PROOF, gp::base64url(&p.to_bytes())));
|
|
}
|
|
let (st, hs, v) = call(addr, "POST", path, Some(body), &headers).await;
|
|
self.challenge = hs.get(&gp::HEADER_CHALLENGE.to_ascii_lowercase()).and_then(|c| gp::from_base64url(c)).and_then(|b| b.try_into().ok());
|
|
if st != 401 {
|
|
return (st, v);
|
|
}
|
|
}
|
|
(401, Value::Null)
|
|
}
|
|
}
|
|
|
|
async fn session(server: &RelayServer, who: &Who) -> Client {
|
|
let url = format!("ws://{}{}", server.websocket_addr.unwrap(), server.relay().config().path);
|
|
let dial = || async { Ok::<joule_moq_relay::transport::SharedConnection, String>(Arc::new(websocket::connect(&url, MuxConfig::default()).await.map_err(|e| e.to_string())?)) };
|
|
let opts = ClientOptions { grants: vec![(who.grant.clone(), who.key.clone())], target: format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path), ..ClientOptions::default() };
|
|
// The relay reads this SETUP before it sends its own, so the grant is proved over a
|
|
// challenge its SETUP gave: on a second session, where the first held none.
|
|
Client::connect_proving(dial, opts).await.expect("SETUP")
|
|
}
|
|
|
|
/// Publish `fields` of `who`'s own namespace, each track `0` at an alias of its own.
|
|
async fn publish(c: &Client, room: &str, who: &SigningKey, fields: &[(&str, u64)]) -> Vec<(joule_moq_relay::wire::ReqTx, joule_moq_relay::wire::ReqRx)> {
|
|
let mut held = Vec::new();
|
|
for (field, alias) in fields {
|
|
let ns = TrackNamespace::from_strs([room, pubhex(who).as_str(), field]);
|
|
let mut p = Properties::new();
|
|
p.push(KeyValuePair::varint(prop::DEFAULT_PUBLISHER_PRIORITY, 64));
|
|
held.push(c.publish(ns, TrackName::from_str("0"), *alias, Parameters::new(), p).await.expect("PUBLISH"));
|
|
}
|
|
held
|
|
}
|
|
|
|
/// One object of group `g` at `alias`, on a stream of its own.
|
|
async fn send(c: &Client, alias: u64, g: u64, payload: Vec<u8>) {
|
|
let h = SubgroupHeader { track_alias: alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: false, end_of_group: true, first_object: true };
|
|
c.send_subgroup(h, &[SubgroupObject::normal(0, payload)]).await.expect("an object");
|
|
}
|
|
|
|
/// A `WAI1` envelope whose `target` states `due` on the room clock, as a page in a room with
|
|
/// co-performance stamps its media.
|
|
fn envelope(due: u64, body: &[u8]) -> Vec<u8> {
|
|
let m = json!({"wai": "1.0", "media": "audio", "intent": "replicate", "model_requirement": {"capability": "wai.audio.opus", "fallback": null}, "conditioning": {"kind": "opus"}, "target": {"timestamp_us": due}}).to_string();
|
|
let mut b = b"WAI1".to_vec();
|
|
b.extend_from_slice(&(m.len() as u32).to_le_bytes());
|
|
b.extend_from_slice(m.as_bytes());
|
|
b.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
|
b.extend_from_slice(body);
|
|
b
|
|
}
|
|
|
|
/// A principal's events track as a page writes it: each record an object of its own.
|
|
struct Events {
|
|
key: SigningKey,
|
|
room: [u8; 32],
|
|
t0: u64,
|
|
seq: u64,
|
|
prev: [u8; 32],
|
|
alias: u64,
|
|
}
|
|
|
|
impl Events {
|
|
async fn emit(&mut self, c: &Client, t: u64, k: u8, payload: Vec<u8>) -> [u8; 32] {
|
|
let ev = Event::sign(&self.key, self.room, self.seq, self.prev, t, None, k, payload);
|
|
let rec = ev.to_bytes();
|
|
let entry = wai::live_events::to_msf_record(&rec, self.t0).unwrap();
|
|
send(c, self.alias, self.seq, format!("[{entry}]").into_bytes()).await;
|
|
self.prev = ev.digest();
|
|
self.seq += 1;
|
|
ev.digest()
|
|
}
|
|
}
|
|
|
|
/// Every record of a principal's events track as the relay serves it, by `seq`.
|
|
async fn chain(c: &Client, room: &str, who: &str, t0: u64) -> Vec<Vec<u8>> {
|
|
let ns = TrackNamespace::from_strs([room, who, "events"]);
|
|
let Ok((_, entries)) = c.fetch(ns, TrackName::from_str("0"), Parameters::new()).await else { return Vec::new() };
|
|
let mut by_seq = BTreeMap::new();
|
|
for e in entries {
|
|
let FetchEntry::Object(o) = e else { continue };
|
|
let arr: Vec<Value> = serde_json::from_slice(&o.payload).unwrap();
|
|
for r in arr {
|
|
let rec = wai::live_events::from_msf_record(&r.to_string(), t0).unwrap();
|
|
by_seq.insert(Event::parse(&rec).unwrap().seq, rec);
|
|
}
|
|
}
|
|
by_seq.into_values().collect()
|
|
}
|
|
|
|
fn statements(chain: &[Vec<u8>]) -> Vec<Statement> {
|
|
chain.iter().filter_map(|r| Event::parse(r).ok()).filter(|e| e.kind == kind::META).filter_map(|e| Statement::parse(&e.payload).ok()).collect()
|
|
}
|
|
|
|
fn now_us() -> u64 {
|
|
joule_moq_room::now_us()
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_room_with_co_performance_marks_commits_selects_and_verifies() {
|
|
timeout(Duration::from_secs(240), run()).await.expect("the test finishes in time");
|
|
}
|
|
|
|
async fn run() {
|
|
let keys = Keys::generate();
|
|
let server = relay(&keys).await;
|
|
let cfg = Config::from_toml(&format!(
|
|
"listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"ws://{}{}\"\n[room]\nmax_publishers = 3\njoin_wait_s = 30\n[rate]\nclient_burst = 20\n[co]\naudience_input = 2\nlatency_budget_ms = 1500\nlead = \"first\"\nai = true\nfidelity_every_s = 0\ncommit_after_ms = 800\nlead_ms = 200\n",
|
|
server.websocket_addr.unwrap(),
|
|
server.relay().config().path
|
|
))
|
|
.unwrap();
|
|
let svc = Service::new(Keys::from_toml(&keys.to_toml()).unwrap(), &cfg);
|
|
let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
svc.start();
|
|
tokio::spawn(http::serve(svc.clone(), listener));
|
|
timeout(STEP, async {
|
|
while !svc.connected.load(Ordering::Acquire) {
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("the clock's session opens");
|
|
|
|
// Alice (the lead: the room's first publisher), Bob (an AI participant), two audience members.
|
|
let model = [0x9d; 32];
|
|
let grant = |k: SigningKey, body: Value| {
|
|
async move {
|
|
let (st, _, g) = call(addr, "POST", "/grant", Some(&body), &[]).await;
|
|
assert_eq!(st, 200, "{g}");
|
|
(Who { key: k, grant: hex::decode(g["grant"].as_str().unwrap()).unwrap(), challenge: None }, g)
|
|
}
|
|
};
|
|
let (alice_k, bob_k, vera_k, val_k) = (key(0x31), key(0x32), key(0x33), key(0x34));
|
|
let (mut alice, ga) = grant(alice_k.clone(), json!({"principal": pubhex(&alice_k), "role": "publisher"})).await;
|
|
let (mut bob, _) = grant(bob_k.clone(), json!({"principal": pubhex(&bob_k), "role": "publisher", "ai": hex::encode(model)})).await;
|
|
let (mut vera, gv) = grant(vera_k.clone(), json!({"principal": pubhex(&vera_k), "role": "viewer"})).await;
|
|
let (val, _) = grant(val_k.clone(), json!({"principal": pubhex(&val_k), "role": "viewer"})).await;
|
|
let room = ga["room"]["room"].as_str().unwrap().to_string();
|
|
let room_id: [u8; 32] = hex::decode(&room).unwrap().try_into().unwrap();
|
|
let t0 = ga["room"]["t0_us"].as_u64().unwrap();
|
|
assert!(gv["room"]["co"]["policy"].is_string(), "a room with co-performance states its policy");
|
|
let rc = || now_us() - t0;
|
|
|
|
// Everyone enters; the clock records the publishers joining when their video appears, and
|
|
// the audience members when their events track does.
|
|
let ca = session(&server, &alice).await;
|
|
let cb = session(&server, &bob).await;
|
|
let cv = session(&server, &vera).await;
|
|
let cl = session(&server, &val).await;
|
|
let _ha = publish(&ca, &room, &alice_k, &[("audio", 10), ("video", 11), ("events", 12)]).await;
|
|
let _hb = publish(&cb, &room, &bob_k, &[("audio", 10), ("video", 11), ("events", 12)]).await;
|
|
let _hv = publish(&cv, &room, &vera_k, &[("events", 12)]).await;
|
|
let _hl = publish(&cl, &room, &val_k, &[("events", 12)]).await;
|
|
let clock = hex::encode(keys.clock.verifying_key().to_bytes());
|
|
let watcher = session(&server, &Who { key: val_k.clone(), grant: val.grant.clone(), challenge: None }).await;
|
|
let mut joins = 0;
|
|
for _ in 0..200 {
|
|
joins = chain(&watcher, &room, &clock, t0).await.iter().filter(|r| Event::parse(r).unwrap().kind == kind::JOIN).count();
|
|
if joins == 4 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
assert_eq!(joins, 4, "two publishers and two audience members joined");
|
|
|
|
// Requests the service refuses: no proof (answered with a challenge), a schedule from a
|
|
// grant without the direct power, a proof presented twice.
|
|
let sched = |from: u64| json!({"mode": "mark", "from_us": from, "segments": [{"start_us": from, "length": 2_000_000, "turns": 4, "pairs": false, "holders": [pubhex(&alice_k), pubhex(&bob_k)]}]});
|
|
let (st, hs, v) = call(addr, "POST", "/co/schedule", Some(&sched(rc() + S)), &[("Authorization", format!("Bearer {}", gp::base64url(&alice.grant)))]).await;
|
|
assert!(st == 401 && v["reason"] == "pop_missing" && hs.contains_key("wai-room-challenge"), "{st} {v}");
|
|
let (st, v) = bob.ask(addr, "/co/schedule", &sched(rc() + S)).await;
|
|
assert_eq!(st, 403, "{v}");
|
|
let c = gp::from_base64url(&hs["wai-room-challenge"]).unwrap().try_into().unwrap();
|
|
let (digest, _) = gp::grant_digest_and_principal(&alice.grant).unwrap();
|
|
let proof = gp::base64url(&GrantProof::sign(digest, &alice_k, c, &gp::context("POST", "room.test", "/co/fidelity")).unwrap().to_bytes());
|
|
let headers = [("Authorization", format!("Bearer {}", gp::base64url(&alice.grant))), (gp::HEADER_PROOF, proof)];
|
|
let (st, _, _) = call(addr, "POST", "/co/fidelity", Some(&json!({})), &headers).await;
|
|
assert_eq!(st, 200, "the proof passes once");
|
|
let (st, _, v) = call(addr, "POST", "/co/fidelity", Some(&json!({})), &headers).await;
|
|
assert!(st == 401 && v["reason"] == "pop_replayed", "{st} {v}");
|
|
|
|
// Alice states a schedule that marks: Alice and Bob trade two-second turns, Alice first.
|
|
let from = rc() + S;
|
|
let (st, v) = alice.ask(addr, "/co/schedule", &sched(from)).await;
|
|
assert_eq!(st, 200, "{v}");
|
|
let from = Statement::parse(&hex::decode(v["statement"].as_str().unwrap()).unwrap()).map(|s| match s {
|
|
Statement::Schedule(x) => x.from,
|
|
_ => panic!("a schedule"),
|
|
});
|
|
let from = from.unwrap();
|
|
let until = |x: u64| async move {
|
|
let now = now_us() - t0;
|
|
if x > now {
|
|
tokio::time::sleep(Duration::from_micros(x - now)).await;
|
|
}
|
|
};
|
|
|
|
// Bob, an AI participant, announces two lines; it says the first as announced, the second
|
|
// not. Its captions state that a machine made them, with its model.
|
|
let mut eb = Events { key: bob_k.clone(), room: room_id, t0, seq: 0, prev: [0; 32], alias: 12 };
|
|
let line = |at: u64, text: &str| Statement::Intent(co::Intent { track: [0; 32], at, span: S as u32, plan: co::Plan::Text(text.into()) }).encode();
|
|
let caption = |text: &str| Payload::Caption(Caption { duration_ms: 1000, origin: 2, model: Some(model), lang: "en".into(), text: text.into() }).encode();
|
|
let (at1, at2) = (from + 2_500_000, from + 3_500_000);
|
|
eb.emit(&cb, rc(), kind::META, line(at1, "here comes the bridge")).await;
|
|
eb.emit(&cb, rc(), kind::META, line(at2, "and back to the top")).await;
|
|
|
|
// Alice's turn: her object in turn, Bob's out of turn.
|
|
until(from + 500_000).await;
|
|
send(&ca, 10, 1, envelope(rc(), b"alice in her turn")).await;
|
|
until(from + 1_000_000).await;
|
|
send(&cb, 10, 1, envelope(rc(), b"bob out of turn")).await;
|
|
// Bob's turn: its first line as announced, a late object, its second line otherwise.
|
|
until(at1).await;
|
|
eb.emit(&cb, at1, kind::CAPTION, caption("here comes the bridge")).await;
|
|
send(&cb, 10, 2, envelope(rc().saturating_sub(3 * S), b"bob, three seconds late")).await;
|
|
until(at2).await;
|
|
eb.emit(&cb, at2, kind::CAPTION, caption("and somewhere else")).await;
|
|
|
|
// The audience: a vote of three options, closing in three seconds; both vote for option 1,
|
|
// and Vera cannot open a window or hand a turn over.
|
|
let (st, v) = vera.ask(addr, "/co/window", &json!({"rule": "plurality", "options": 3, "k": 1, "seconds": 3, "question": "which way?"})).await;
|
|
assert_eq!(st, 403, "{v}");
|
|
let (st, v) = alice.ask(addr, "/co/window", &json!({"rule": "plurality", "options": 3, "k": 1, "seconds": 3, "question": "which way?"})).await;
|
|
assert_eq!(st, 200, "{v}");
|
|
let w: [u8; 32] = hex::decode(v["event"].as_str().unwrap()).unwrap().try_into().unwrap();
|
|
let vote = |o| Statement::Input(co::AudienceInput { window: w, body: co::InputBody::Vote(o) }).encode();
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
let mut ev = Events { key: vera_k.clone(), room: room_id, t0, seq: 0, prev: [0; 32], alias: 12 };
|
|
let mut el = Events { key: val_k.clone(), room: room_id, t0, seq: 0, prev: [0; 32], alias: 12 };
|
|
ev.emit(&cv, rc(), kind::META, vote(1)).await;
|
|
el.emit(&cl, rc(), kind::META, vote(1)).await;
|
|
let mut sel = None;
|
|
for _ in 0..100 {
|
|
sel = statements(&chain(&watcher, &room, &clock, t0).await).into_iter().find_map(|s| match s {
|
|
Statement::Selection(x) if x.window == w => Some(x),
|
|
_ => None,
|
|
});
|
|
if sel.is_some() {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
}
|
|
let sel = sel.expect("the clock selects once the window closes");
|
|
assert_eq!((sel.picks, sel.credits[0].count), (co::Picks::Options(vec![1]), 2));
|
|
|
|
// Once the lines are some seconds past, Alice asks for the fidelity statement.
|
|
until(at2 + co_lag() + S).await;
|
|
let (st, v) = alice.ask(addr, "/co/fidelity", &json!({})).await;
|
|
assert!(st == 200 && v["ok"] == true, "{v}");
|
|
|
|
// The room as the relay serves it, and the clock's log of objects.
|
|
let (_, _, r) = call(addr, "GET", "/room", None, &[]).await;
|
|
let descriptor = hex::decode(r["descriptor"].as_str().unwrap()).unwrap();
|
|
let grants: Vec<Vec<u8>> = r["grants"].as_array().unwrap().iter().chain(r["co"]["audience"].as_array().unwrap()).map(|g| hex::decode(g.as_str().unwrap()).unwrap()).collect();
|
|
let policies = vec![hex::decode(r["co"]["policy"].as_str().unwrap()).unwrap()];
|
|
let mut chains = vec![chain(&watcher, &room, &clock, t0).await];
|
|
for k in [&alice_k, &bob_k, &vera_k, &val_k] {
|
|
chains.push(chain(&watcher, &room, &pubhex(k), t0).await);
|
|
}
|
|
let (_, _, log) = call(addr, "GET", "/co/objects?from=0", None, &[]).await;
|
|
let objects: Vec<co::Object> = log["objects"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|o| co::Object {
|
|
principal: hex::decode(o["principal"].as_str().unwrap()).unwrap().try_into().unwrap(),
|
|
track: hex::decode(o["track"].as_str().unwrap()).unwrap().try_into().unwrap(),
|
|
group: o["group"].as_u64().unwrap(),
|
|
object: o["object"].as_u64().unwrap(),
|
|
digest: hex::decode(o["digest"].as_str().unwrap()).unwrap().try_into().unwrap(),
|
|
arrival: o["arrival"].as_u64().unwrap(),
|
|
due: o["due"].as_u64().unwrap(),
|
|
})
|
|
.collect();
|
|
assert_eq!(objects.len(), 3, "{log}");
|
|
let run = co::verify(&co::Input { descriptor: &descriptor, grants: &grants, head: None, chains: &chains, policies: &policies, objects: &objects, payloads: &[] });
|
|
assert_eq!(run.outcome, co::Outcome::Accept, "the room verifies");
|
|
let floor: Vec<(co::Floor, bool, u8)> = run.objects.iter().map(|o| (o.floor, o.late, o.marks)).collect();
|
|
assert_eq!(floor, vec![(co::Floor::InTurn, false, 0), (co::Floor::OutOfTurn, false, co::flag::OUT_OF_TURN), (co::Floor::InTurn, true, co::flag::LATE)]);
|
|
let kept: Vec<&str> = run.intents.iter().map(|i| i.outcome.name()).collect();
|
|
assert_eq!(kept, ["kept", "diverged"]);
|
|
assert_eq!(run.fidelity, vec![(pk(&bob_k), co::Fidelity { announced: 2, kept: 1, diverged: 1, missed: 0 })]);
|
|
assert!(statements(&chains[0]).iter().any(|s| matches!(s, Statement::Fidelity(f) if f.entries == run.fidelity)), "the clock's fidelity statement, recomputed");
|
|
assert_eq!(run.windows[0].picks, Some(co::Picks::Options(vec![1])));
|
|
|
|
// An object the clock did not take, stated out of turn with no mark, is found out.
|
|
let mut stray = objects.clone();
|
|
stray.push(co::Object { group: 9, arrival: objects[1].arrival, due: objects[1].arrival, ..objects[1] });
|
|
let run = co::verify(&co::Input { descriptor: &descriptor, grants: &grants, head: None, chains: &chains, policies: &policies, objects: &stray, payloads: &[] });
|
|
assert_eq!(run.outcome, co::Outcome::Refuse { at: co::At::Object(3), reason: co::Reason::TurnUnmarked });
|
|
for c in [&ca, &cb, &cv, &cl, &watcher] {
|
|
c.close(0, "done");
|
|
}
|
|
server.shutdown().await;
|
|
}
|
|
|
|
fn co_lag() -> u64 {
|
|
joule_moq_room::co::FIDELITY_LAG_US
|
|
}
|