From 56772d521848f9978ffbed5849ab08eaf901da34 Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Sat, 7 Mar 2026 17:05:40 +0100 Subject: [PATCH 01/13] Force lock on start-vote to prevent concurrent requests from messing up round keys --- rustsystem-server/src/api/host/start_vote.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/rustsystem-server/src/api/host/start_vote.rs b/rustsystem-server/src/api/host/start_vote.rs index f4d87d7..f765328 100644 --- a/rustsystem-server/src/api/host/start_vote.rs +++ b/rustsystem-server/src/api/host/start_vote.rs @@ -52,20 +52,24 @@ impl APIHandler for StartVote { let candidates = metadata.get_candidates(); let num_candidates = candidates.len(); - // Ask trustauth to generate a BLS keypair for this round; it owns the private key. - let public_key = state - .start_round_on_trustauth(auth.muuid, &body.name) - .await?; - let meeting = state.get_meeting(auth.muuid).await?; // Hold the vote_auth write lock for the whole check-and-start sequence to // prevent a concurrent start-vote from racing past the is_inactive check. + // The state guard is acquired BEFORE calling trustauth so that a rejected + // duplicate never causes trustauth to replace the active round's keypair. let mut vote_auth = meeting.vote_auth.write().await; if !vote_auth.is_inactive() { return Err(APIError::from_error_code(APIErrorCode::InvalidState)); } + // Ask trustauth to generate a BLS keypair for this round; it owns the private key. + // Called only after confirming the server is in Idle state, so the round is + // created in trustauth if and only if the server will also transition to Voting. + let public_key = state + .start_round_on_trustauth(auth.muuid, &body.name) + .await?; + // Remove unclaimed voters and mark the meeting as locked. // Acquiring voters.write() while holding vote_auth.write() is safe: no other // operation holds voters.write() and then waits for vote_auth.write(). From 636d0a0d6f10fee3c513513ab5e3b50689343804 Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Wed, 11 Mar 2026 16:36:33 +0100 Subject: [PATCH 02/13] Add load test (e2e) in frontend --- .../src/signatures/e2e-tests/load.test.ts | 613 ++++++++++++++++++ 1 file changed, 613 insertions(+) create mode 100644 frontend/src/signatures/e2e-tests/load.test.ts diff --git a/frontend/src/signatures/e2e-tests/load.test.ts b/frontend/src/signatures/e2e-tests/load.test.ts new file mode 100644 index 0000000..c088431 --- /dev/null +++ b/frontend/src/signatures/e2e-tests/load.test.ts @@ -0,0 +1,613 @@ +/** + * @vitest-environment node + * + * Load / concurrency tests for the full voting system. + * + * Three scenarios: + * + * 1. Concurrent host invites (N_MEETINGS × VOTERS_PER_MEETING) + * Multiple meetings are created simultaneously, then every host + * concurrently adds all its voters in a single Promise.all wave. + * Verifies there are no lock-ordering bugs or races in the host + * invite path when meetings are written in parallel. + * + * 2. 50 simultaneous registrations + * One meeting, 50 logged-in voters, all fire POST /api/register at the + * same time. Checks that trustauth's duplicate-prevention guard and the + * shared RoundState lock hold up under concurrent load. + * + * 3. 200-voter end-to-end flow + * Full protocol at scale: create meeting → add 200 voters → log in all + * 200 (batched) → start vote → all 200 register simultaneously → all + * 200 get vote data and submit simultaneously → tally → verify counts. + * + * Why ConcurrentClient instead of TestClient? + * TestClient.withSession() patches globalThis.fetch to inject cookies and + * rewrite relative URLs. That design is safe when only one client is active + * at a time, but breaks under concurrency: two clients racing on withSession() + * will overwrite each other's global fetch. ConcurrentClient avoids the + * global patch entirely — it calls fetch directly with explicit absolute URLs + * and keeps its own per-instance cookie jar. + * + * Requires both services running: + * cargo run --bin rustsystem-server + * cargo run --bin rustsystem-trustauth + * pnpm test src/signatures/e2e-tests/load.test.ts + */ + +import { generateToken, buildBallot, uuidToBytes } from "../signatures"; +import type { BallotMetaData } from "../signatures"; +import { BASE_URL, TRUSTAUTH_URL, DEFAULT_METADATA } from "./helpers"; +import type { TallyResult, VoteData } from "../voteSession"; + +// ── Service reachability ─────────────────────────────────────────────────────── + +const servicesReachable: boolean = await Promise.all([ + fetch(`${BASE_URL}/`) + .then(() => true) + .catch(() => false), + fetch(`${TRUSTAUTH_URL}/api/is-registered`) + .then(() => true) + .catch(() => false), +]).then(([s, t]) => s && t); + +// ── ConcurrentClient ────────────────────────────────────────────────────────── + +/** + * A self-contained, concurrency-safe HTTP client with its own cookie jar. + * + * Unlike TestClient it does NOT patch globalThis.fetch. Every method calls + * the native fetch directly using explicit absolute URLs, so many + * ConcurrentClient instances can run in parallel without interfering. + */ +class ConcurrentClient { + private cookieJar = ""; + + private storeCookies(headers: Headers): void { + // Node 20.10+ exposes getSetCookie(); fall back to splitting the combined + // header string (older runtimes / undici behaviour). + type H = { getSetCookie?(): string[] }; + const rawList: string[] = + typeof (headers as unknown as H).getSetCookie === "function" + ? (headers as unknown as H).getSetCookie!() + : (headers.get("set-cookie") ?? "").split(/\n/).filter(Boolean); + + for (const raw of rawList) { + const nameValue = raw.split(";")[0].trim(); + if (!nameValue.includes("=")) continue; + const name = nameValue.split("=")[0]; + const re = new RegExp(`(?:^|; *)${name}=[^;]*`); + this.cookieJar = this.cookieJar + ? re.test(this.cookieJar) + ? this.cookieJar.replace(re, nameValue) + : `${this.cookieJar}; ${nameValue}` + : nameValue; + } + } + + /** Send a request, injecting stored cookies and persisting Set-Cookie headers. */ + private async req(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers as HeadersInit | undefined); + if (!headers.has("Content-Type")) + headers.set("Content-Type", "application/json"); + if (this.cookieJar) headers.set("Cookie", this.cookieJar); + + const res = await fetch(url, { ...init, headers }); + this.storeCookies(res.headers); + return res; + } + + // ── Meeting setup ──────────────────────────────────────────────────────────── + + /** + * POST /api/create-meeting (server) then POST /api/login (trustauth). + * Returns the session IDs for subsequent calls. + */ + async createMeeting( + title = "Load Test", + hostName = "Test Host", + ): Promise<{ muuid: string; uuuid: string }> { + const serverRes = await this.req(`${BASE_URL}/api/create-meeting`, { + method: "POST", + body: JSON.stringify({ title, host_name: hostName, pub_key: "test-key" }), + }); + if (!serverRes.ok) + throw new Error(`createMeeting HTTP ${serverRes.status}`); + + const data = await serverRes.json(); + const ids = { muuid: data.muuid as string, uuuid: data.uuuid as string }; + + const trustRes = await this.req(`${TRUSTAUTH_URL}/api/login`, { + method: "POST", + body: JSON.stringify(ids), + }); + if (!trustRes.ok) + throw new Error(`trustauth createMeeting login HTTP ${trustRes.status}`); + + return ids; + } + + /** + * POST /api/host/new-voter — add a voter and return the invite link. + * Requires a host session (cookie set by createMeeting). + */ + async addVoter(name: string, isHost = false): Promise { + const res = await this.req(`${BASE_URL}/api/host/new-voter`, { + method: "POST", + body: JSON.stringify({ voterName: name, isHost }), + }); + if (!res.ok) throw new Error(`addVoter HTTP ${res.status}`); + const data = await res.json(); + return data.inviteLink as string; + } + + /** + * Log in by following an invite link (voter or host). + * + * Host invite links carry admin_msg (hex-encoded bytes) and admin_sig as + * extra query parameters. When present they are forwarded as admin_cred in + * the login body, causing the server to issue a host JWT (is_host=true). + * Without them a regular voter JWT is issued. Mirrors the logic in login.tsx. + */ + async loginFromInviteLink( + inviteLink: string, + ): Promise<{ muuid: string; uuuid: string }> { + // The invite link may be a full URL or a relative path; URL() handles both. + const url = new URL(inviteLink, BASE_URL); + const muuid = url.searchParams.get("muuid")!; + const uuuid = url.searchParams.get("uuuid")!; + const adminMsg = url.searchParams.get("admin_msg"); + const adminSig = url.searchParams.get("admin_sig"); + const admin_cred = + adminMsg && adminSig + ? { msg: adminMsg.match(/.{2}/g)!.map((b) => parseInt(b, 16)), sig: adminSig } + : undefined; + + const serverRes = await this.req(`${BASE_URL}/api/login`, { + method: "POST", + body: JSON.stringify({ uuuid, muuid, admin_cred }), + }); + if (!serverRes.ok) + throw new Error(`voter server login HTTP ${serverRes.status}`); + + const trustRes = await this.req(`${TRUSTAUTH_URL}/api/login`, { + method: "POST", + body: JSON.stringify({ uuuid, muuid }), + }); + if (!trustRes.ok) + throw new Error(`voter trustauth login HTTP ${trustRes.status}`); + + return { muuid, uuuid }; + } + + // ── Vote round management ──────────────────────────────────────────────────── + + async startVoteRound( + name = "Load Test Vote", + metadata: BallotMetaData = DEFAULT_METADATA, + ): Promise { + const res = await this.req(`${BASE_URL}/api/host/start-vote`, { + method: "POST", + body: JSON.stringify({ name, shuffle: false, metadata }), + }); + if (!res.ok) throw new Error(`startVoteRound HTTP ${res.status}`); + } + + // ── Voter workflow ──────────────────────────────────────────────────────────── + + /** + * POST /api/register on trustauth — generate a blind-signature commitment + * and get back a blind signature. The token and blind_factor are derived + * from the voter's session IDs and kept in memory for later ballot building. + * + * Stores the generated token/blind_factor in this client so getVoteData() + * can serve them from the trustauth /api/vote-data endpoint. + */ + async registerVoter(session: { + uuuid: string; + muuid: string; + }): Promise { + const tokenData = generateToken( + uuidToBytes(session.uuuid), + uuidToBytes(session.muuid), + ); + const res = await this.req(`${TRUSTAUTH_URL}/api/register`, { + method: "POST", + body: JSON.stringify({ + context: tokenData.context, + commitment: tokenData.commitmentJson, + token: Array.from(tokenData.token), + blind_factor: Array.from(tokenData.blindFactor), + }), + }); + if (!res.ok) throw new Error(`registerVoter HTTP ${res.status}`); + } + + /** + * GET /api/vote-data — retrieve the stored token, blind_factor, and blind + * signature from trustauth. Must be called after registerVoter. + */ + async getVoteData(): Promise { + const res = await this.req(`${TRUSTAUTH_URL}/api/vote-data`); + if (!res.ok) throw new Error(`getVoteData HTTP ${res.status}`); + return res.json() as Promise; + } + + /** + * Build a ballot from the stored vote data and POST it to /api/voter/submit. + * @param choice Array of candidate indices, or null for a blank vote. + */ + async submitVote( + voteData: VoteData, + choice: number[] | null, + metadata: BallotMetaData = DEFAULT_METADATA, + ): Promise { + const ballot = buildBallot( + metadata, + choice, + new Uint8Array(voteData.token), + new Uint8Array(voteData.blind_factor), + voteData.signature, + ); + const res = await this.req(`${BASE_URL}/api/voter/submit`, { + method: "POST", + body: JSON.stringify(ballot), + }); + if (!res.ok) throw new Error(`submitVote HTTP ${res.status}`); + } + + // ── Tally & cleanup ────────────────────────────────────────────────────────── + + async endVoteRound(): Promise { + const res = await this.req(`${BASE_URL}/api/host/end-vote-round`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(`endVoteRound HTTP ${res.status}`); + } + + async tally(): Promise { + const res = await this.req(`${BASE_URL}/api/host/tally`, { + method: "POST", + }); + if (!res.ok) throw new Error(`tally HTTP ${res.status}`); + return res.json() as Promise; + } + + async closeMeeting(): Promise { + await this.req(`${BASE_URL}/api/host/close-meeting`, { method: "DELETE" }); + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** + * Execute `tasks` with at most `batchSize` tasks running concurrently. + * Preserves insertion order in the returned results array. + */ +async function runInBatches( + tasks: Array<() => Promise>, + batchSize: number, +): Promise { + const results: T[] = []; + for (let i = 0; i < tasks.length; i += batchSize) { + const wave = tasks.slice(i, i + batchSize).map((fn) => fn()); + results.push(...(await Promise.all(wave))); + } + return results; +} + +// ── Test suites ──────────────────────────────────────────────────────────────── + +// ─── 1. Concurrent host invites ──────────────────────────────────────────────── + +describe.skipIf(!servicesReachable)("concurrent host invites", () => { + /** + * Five meetings are created simultaneously; then every host adds twenty + * voters at the same time, giving 100 parallel new-voter requests spread + * across five independent meetings. We verify: + * • all 100 calls succeed (no 5xx or deadlock) + * • every returned invite link contains the expected query parameters + * + * This exercises write-path concurrency: the outer AppState read-lock is + * held briefly to clone each Arc, then per-meeting voter locks are + * acquired independently. Parallel writes to different meetings must not + * interfere. + */ + it( + "5 hosts each add 20 voters concurrently — all 100 invite links are valid", + async () => { + const N_MEETINGS = 5; + const VOTERS_PER_MEETING = 20; + + // ── Create all meetings concurrently ──────────────────────────────────── + const hosts = await Promise.all( + Array.from({ length: N_MEETINGS }, async (_, i) => { + const host = new ConcurrentClient(); + await host.createMeeting(`Concurrent Meeting ${i + 1}`, `Host ${i + 1}`); + return host; + }), + ); + + // ── Every host adds all its voters simultaneously ──────────────────────── + // Flatten to a single Promise.all so all 100 requests fire at once. + const allTasks = hosts.flatMap((host, hi) => + Array.from({ length: VOTERS_PER_MEETING }, (_, vi) => () => + host.addVoter(`M${hi + 1} Voter ${vi + 1}`), + ), + ); + const inviteLinks = await Promise.all(allTasks.map((fn) => fn())); + + // ── Assertions ────────────────────────────────────────────────────────── + expect(inviteLinks).toHaveLength(N_MEETINGS * VOTERS_PER_MEETING); + for (const link of inviteLinks) { + expect(link).toMatch(/\/login\?/); + expect(link).toMatch(/muuid=/); + expect(link).toMatch(/uuuid=/); + } + + // ── Cleanup ───────────────────────────────────────────────────────────── + await Promise.all(hosts.map((host) => host.closeMeeting())); + }, + { timeout: 60_000 }, + ); +}); + +// ─── 2. 50 simultaneous registrations ───────────────────────────────────────── + +describe.skipIf(!servicesReachable)("50 simultaneous registrations", () => { + /** + * Fifty voters all call POST /api/register on trustauth at the same time. + * + * The trustauth RoundState holds an async mutex that guards the registered- + * voters set. This test checks that: + * • all 50 registrations complete successfully under contention + * • no voter is incorrectly rejected as a duplicate (each client has a + * distinct JWT, so trustauth must serialise the set writes correctly) + * + * After registration we start a tally to confirm the server session is still + * consistent (tally count = 0 since no votes were submitted). + */ + it( + "all 50 registrations succeed with no duplicates or errors", + async () => { + const N = 50; + + // ── Setup: one meeting, N voters ──────────────────────────────────────── + const host = new ConcurrentClient(); + await host.createMeeting("50-Reg Load Test", "Host"); + + // Add all voters via host API (parallel — exercises write path too) + const inviteLinks = await Promise.all( + Array.from({ length: N }, (_, i) => host.addVoter(`Voter ${i + 1}`)), + ); + + // Log in all voters before start-vote so they are not pruned. + // Use a batch size of 25 to avoid saturating the login endpoint. + const voterSessions = await runInBatches( + inviteLinks.map((link) => async () => { + const voter = new ConcurrentClient(); + const session = await voter.loginFromInviteLink(link); + return { voter, session }; + }), + 25, + ); + + // ── Start vote round ──────────────────────────────────────────────────── + await host.startVoteRound("Concurrent Registration Test"); + + // ── All 50 voters register simultaneously ──────────────────────────────── + // This is the core of the test: 50 concurrent writes to the trustauth + // registered-voters set. Every one must succeed (no AlreadyRegistered 409). + await Promise.all( + voterSessions.map(({ voter, session }) => voter.registerVoter(session)), + ); + + // ── Tally: no votes submitted, all counts must be zero ────────────────── + const result = await host.tally(); + expect(result.blank).toBe(0); + for (const candidate of DEFAULT_METADATA.candidates) { + expect(result.score[candidate]).toBe(0); + } + + // ── Cleanup ───────────────────────────────────────────────────────────── + await host.closeMeeting(); + }, + { timeout: 3 * 60_000 }, + ); +}); + +// ─── 3. 500-voter multi-round stress test ───────────────────────────────────── + +describe.skipIf(!servicesReachable)("500-voter full system flow", () => { + /** + * The entire voting protocol under maximum concurrency with 500 participants + * across three consecutive vote rounds. + * + * Every step is a single Promise.all — no batching anywhere. The server and + * trustauth face worst-case burst load at every phase, and the state machine + * must transition cleanly through Idle → Voting → Tally → Idle three times. + * + * Multi-host invite phase (runs once, before all rounds): + * One master host creates the meeting and promotes 9 co-hosts. All 10 hosts + * then add their 50 voters simultaneously (500 concurrent new-voter writes + * across 10 host JWTs on the same meeting). All 500 voters log in at once + * before start-vote prunes unclaimed slots. + * + * Per-round phase (repeated for each round): + * start-vote → 500 register simultaneously → 500 get-vote-data simultaneously + * → 500 submit simultaneously → tally → assert exact counts → end-vote-round + * + * Round distributions (500 voters each): + * Round 1 — mixed: 125 blank / 150 A / 125 B / 100 C + * Round 2 — no blanks: 0 blank / 200 A / 150 B / 150 C + * Round 3 — all blank: 500 blank / 0 A / 0 B / 0 C + * + * State machine transitions under stress: + * Idle →(start-vote)→ Voting →(tally)→ Tally →(end-vote-round)→ Idle ×3 + * A second start-vote while Voting must 409; tallying while Idle must 410. + * Both are verified after every round transition. + */ + it( + "500 voters across 3 rounds — state machine stays consistent, tallies match", + async () => { + const N = 500; + const N_COHOSTS = 9; // + 1 master = 10 total host clients + const N_HOSTS = N_COHOSTS + 1; + const VOTERS_PER_HOST = N / N_HOSTS; // 50 voters per host + + // Per-round vote distributions. choices[i] is indexed by voter position + // in the flat invite-link array produced during setup. + const ROUNDS = [ + { + name: "Round 1 — mixed", + choices: [ + ...Array(125).fill(null), // blank + ...Array(150).fill([0]), // Option A + ...Array(125).fill([1]), // Option B + ...Array(100).fill([2]), // Option C + ] as Array, + expected: { blank: 125, A: 150, B: 125, C: 100 }, + }, + { + name: "Round 2 — no blanks", + choices: [ + ...Array(200).fill([0]), // Option A + ...Array(150).fill([1]), // Option B + ...Array(150).fill([2]), // Option C + ] as Array, + expected: { blank: 0, A: 200, B: 150, C: 150 }, + }, + { + name: "Round 3 — all blank", + choices: Array(N).fill(null) as Array, + expected: { blank: 500, A: 0, B: 0, C: 0 }, + }, + ]; + + // ── 1. Master creates the meeting ────────────────────────────────────── + const master = new ConcurrentClient(); + await master.createMeeting("500-Voter Multi-Round Test", "Master Host"); + + // ── 2. Master creates 9 co-host invite links concurrently ────────────── + const coHostLinks = await Promise.all( + Array.from({ length: N_COHOSTS }, (_, i) => + master.addVoter(`Co-Host ${i + 1}`, /* isHost */ true), + ), + ); + + // ── 3. All co-hosts log in concurrently ──────────────────────────────── + const coHosts = await Promise.all( + coHostLinks.map(async (link) => { + const client = new ConcurrentClient(); + await client.loginFromInviteLink(link); + return client; + }), + ); + + const allHosts = [master, ...coHosts]; // 10 host clients + + // ── 4. All 10 hosts add their voters simultaneously ──────────────────── + // 500 concurrent new-voter writes across 10 host JWTs on the same meeting. + const inviteLinks = ( + await Promise.all( + allHosts.map((host, hi) => + Promise.all( + Array.from({ length: VOTERS_PER_HOST }, (_, vi) => + host.addVoter(`H${hi + 1} Voter ${vi + 1}`), + ), + ), + ), + ) + ).flat(); + expect(inviteLinks).toHaveLength(N); + + // ── 5. All 500 voters log in simultaneously ──────────────────────────── + // 1 000 HTTP calls in-flight at once (server login + trustauth login per voter). + // Must finish before start-vote, which prunes unclaimed voter slots. + const voterSessions = await Promise.all( + inviteLinks.map(async (link) => { + const voter = new ConcurrentClient(); + const session = await voter.loginFromInviteLink(link); + return { voter, session }; + }), + ); + expect(voterSessions).toHaveLength(N); + + // ── 6. Three vote rounds ─────────────────────────────────────────────── + // ── State machine guard: tally while Idle must fail ─────────────────── + // Verified once before any round to keep the per-round hot path clean. + // Starting a round is tested below; duplicate-start is checked BEFORE the + // round goes live so it can never corrupt an active keypair. + const idleTally = await master["req"](`${BASE_URL}/api/host/tally`, { method: "POST" }); + expect(idleTally.status).toBe(410); // VotingInactive — no active round yet + + for (const [ri, round] of ROUNDS.entries()) { + // ── 6a. Start round ───────────────────────────────────────────────── + await master.startVoteRound(round.name); + + // Duplicate start-vote while Voting must be rejected (409). + // NOTE: this check is safe post-fix because the server now acquires the + // vote_auth write-lock BEFORE calling trustauth, so a rejected duplicate + // never reaches trustauth and cannot replace the active keypair. + const dupStart = await master["req"]( + `${BASE_URL}/api/host/start-vote`, + { method: "POST", body: JSON.stringify({ name: "dup", shuffle: false, metadata: DEFAULT_METADATA }) }, + ); + expect(dupStart.status).toBe(409); + + // ── 6b. All 500 register simultaneously ───────────────────────────── + await Promise.all( + voterSessions.map(({ voter, session }) => + voter.registerVoter(session), + ), + ); + + // ── 6c. All 500 get vote data simultaneously ───────────────────────── + const voteDataList = await Promise.all( + voterSessions.map(({ voter }) => voter.getVoteData()), + ); + + // ── 6d. All 500 submit simultaneously ──────────────────────────────── + await Promise.all( + voterSessions.map(({ voter }, i) => + voter.submitVote(voteDataList[i], round.choices[i]), + ), + ); + + // ── 6e. Tally and verify ───────────────────────────────────────────── + const result = await master.tally(); + + expect(result.blank).toBe(round.expected.blank); + expect(result.score[DEFAULT_METADATA.candidates[0]]).toBe(round.expected.A); + expect(result.score[DEFAULT_METADATA.candidates[1]]).toBe(round.expected.B); + expect(result.score[DEFAULT_METADATA.candidates[2]]).toBe(round.expected.C); + + const total = + result.blank + + Object.values(result.score).reduce((a, b) => a + b, 0); + expect(total).toBe(N); + + // Tallying again from Tally state must be rejected (410). + const dupTally = await master["req"]( + `${BASE_URL}/api/host/tally`, + { method: "POST" }, + ); + expect(dupTally.status).toBe(410); + + // ── 6f. Reset state machine for the next round ─────────────────────── + await master.endVoteRound(); + + // Tallying after reset (Idle state) must also be rejected (410). + const staleTally = await master["req"]( + `${BASE_URL}/api/host/tally`, + { method: "POST" }, + ); + expect(staleTally.status).toBe(410); + } + + // ── 7. Cleanup ────────────────────────────────────────────────────────── + await master.closeMeeting(); + }, + { timeout: 45 * 60_000 }, // 45 minutes — 3 rounds × 500 concurrent voters + ); +}); From aca45942e14d09dcd57117ff6910dce61057ac9f Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Fri, 20 Mar 2026 13:41:18 +0100 Subject: [PATCH 03/13] Remove TODO.md --- TODO.md | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 6cc021f..0000000 --- a/TODO.md +++ /dev/null @@ -1,22 +0,0 @@ -## Needs to be done by November 16th - -### Server Side (and wasm) - -- [x] member list -- [x] delete members -- [ ] Fix warnings... -- [ ] Doc tests for proof -- [x] Fix UUID -- [x] Host Universal Auth Token -- [x] Meeting agenda update as we go - -### Client Side - -- [x] Host/Voter Page -- [x] Start Page -- [x] Invite Voter/Host -- [x] Agenda Viewer -- [ ] Click to enlarge qr code -- [ ] Allow voters to refresh page during voting -- [ ] Format links and nested lists in agenda viewer -- [ ] Tabs in textarea From 6486aabfa387fdb2e82ffd2e5821af972c86842f Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Fri, 20 Mar 2026 14:04:34 +0100 Subject: [PATCH 04/13] Add CONTRIBUTE.md --- CONTRIBUTE.md | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 CONTRIBUTE.md diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md new file mode 100644 index 0000000..ce4a936 --- /dev/null +++ b/CONTRIBUTE.md @@ -0,0 +1,202 @@ +# Contributing Guide + +This document explains how to contribute to Rustsystem: the branch structure, development workflow, and how releases are versioned and documented. + +--- + +## Branch Structure + +Development flows through two long-lived branches (`stage` and `main`) and short-lived feature branches: + +```mermaid +gitGraph + commit id: "main (stable)" + branch stage + checkout stage + commit id: "stage (testing)" + branch add-deletion-endpoint + checkout add-deletion-endpoint + commit id: "feature work" + commit id: "more work" + checkout stage + merge add-deletion-endpoint id: "PR: feature → stage" + commit id: "2.1.0-beta tag" + commit id: "bug fix" + commit id: "2.1.1-beta tag" + checkout main + merge stage id: "PR: stage → main (2.1.1 full release)" +``` + +| Branch | Purpose | +| -------------- | ------------------------------------------------------------------------------------------------ | +| `main` | Stable, production-ready code. Only updated when a beta is promoted to a full release. | +| `stage` | Integration and testing branch. Receives feature PRs; beta tags are created here. | +| `` | Short-lived branch named after the issue being worked on. Branched from and merged into `stage`. | + +### Workflow + +1. **Create a branch off `stage`** named after the issue or feature (e.g. `add-deletion-endpoint`, `fix-sse-reconnect`). +2. **Develop and commit** on your feature branch. +3. **Open a PR from `` → `stage`** when the work is ready for testing. +4. **Test on `stage`**. Beta tags (`x.y.z-beta`) are created here. Fix bugs on `stage` directly or via follow-up PRs. +5. **Open a PR from `stage` → `main`** once a beta passes testing. `main` only ever receives full releases — beta tags remain on `stage`. + +```mermaid +flowchart LR + A["<issue-name>\n(feature branch)"] -->|PR| B["stage\n(testing, beta tags)"] + B -->|bugs found| B + B -->|beta passes PR| C["main\n(full releases only)"] +``` + +--- + +## Versioning + +Versions follow **MAJOR.MINOR.PATCH**, optionally suffixed with `-beta` for pre-releases. + +| Component | When to increment | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **MAJOR** | Complete redesign of the system — e.g. a full frontend and/or backend rewrite, a fundamental change to the cryptographic protocol, or a structural reorganisation so large that upgrading requires rebuilding the deployment from scratch. | +| **MINOR** | Breaking or significant changes — endpoint changes or additions, workflow changes, logic changes, large UI changes. | +| **PATCH** | Small non-breaking changes — documentation fixes, small visual changes, small backend changes that do not affect the API or workflow. | + +### Beta Pre-releases + +Every release that increments **MAJOR or MINOR must first be released as a `-beta` pre-release** and pass testing before it is promoted to a full release. + +The promotion process works as follows: + +```mermaid +flowchart LR + A["x.y.0-beta\n(pre-release)"] --> B{Testing} + B -- bugs found --> C["x.y.1-beta\n(fix)"] + C --> D{Testing} + D -- bugs found --> E["x.y.2-beta\n(fix) ..."] + D -- passed --> F["x.y.1\n(full release)"] + E --> G{Testing} + G -- passed --> H["x.y.2\n(full release)"] +``` + +**The full release tag is placed at the same commit as the accepted pre-release on `stage`, and that commit is then merged into `main` via PR.** No code changes are made between the last `-beta` tag and the full release tag. Beta tags only ever exist on `stage` — `main` only receives full releases. + +> **Example:** `10.11.0-beta` is released on `stage`. Bugs are found and fixed in `10.11.1-beta`, `10.11.2-beta`, `10.11.3-beta`. Once `10.11.3-beta` passes testing, it is tagged `10.11.3` on `stage` and merged into `main`. + +PATCH releases do not require a `-beta` pre-release and may be released directly as a full version. + +--- + +## Release Notes + +All release notes **must** follow this structure. + +### 1. Title + +``` +# Release Notes — v +``` + +### 2. Preamble + +A short block (blockquote) immediately below the title containing: + +- **Date** of release. +- **Built upon** — which version this release is built on top of. +- **Status** — either `pre-release` (with a note on what remains before promotion) or `stable`. + +Example: + +```markdown +> Release date: 2026-04-01 +> Built on top of v2.0.3-beta. +> **Status: stable.** +``` + +```markdown +> Pre-release date: 2026-04-01 +> Built on top of v2.0.0-beta. +> **Status: pre-release — requires stress testing before production use.** +``` + +--- + +### 3. Overview + +A prose summary of the release followed by a **bullet point list** of every problem addressed or change made. The summary should communicate: + +- How large the release is (patch-level tweak, significant feature addition, major overhaul, etc.). +- The motivation — what prompted the changes. + +The bullet list gives a reader a complete picture at a glance before they read the detail sections. + +Example: + +```markdown +## Overview + +v2.1.0-beta is a significant feature release ... + +- New meeting deletion endpoint +- Vote state endpoints added +- Lock ordering corrected in README +``` + +--- + +### 4. Changes + +One subsection per item from the overview bullet list. Each subsection: + +- Explains the change in depth — what was changed, why, and what impact it has. +- Uses **tables** to compare before/after states, list endpoints, or summarise options where that aids clarity. +- Uses **Mermaid diagrams** to illustrate flows, architecture, or sequences where a diagram communicates more than prose. +- Is separated from the next section by a horizontal rule (`---`). + +Example structure: + +```markdown +## Changes + +### New Meeting Deletion Endpoint + +...explanation... + +--- + +### Vote State Endpoints + +...explanation, possibly with a table of endpoints... + +--- + +### README: Lock Ordering Correction + +...explanation... +``` + +#### When to use tables + +Use a table when comparing multiple items across consistent attributes — e.g. a list of endpoints and their roles, a before/after comparison, or a summary of test coverage areas. + +#### When to use Mermaid diagrams + +Use a Mermaid diagram when the relationship between steps, services, or states is easier to understand visually than as prose. Preferred diagram types: + +| Situation | Diagram type | +| -------------------------------- | ----------------- | +| Multi-step flows or processes | `flowchart` | +| Service-to-service communication | `sequenceDiagram` | +| State machines | `stateDiagram-v2` | +| Version/branching timelines | `gitGraph` | + +--- + +## Checklist Before Publishing a Release + +- [ ] Version number follows MAJOR.MINOR.PATCH rules above. +- [ ] If MINOR or MAJOR changed, this is a `-beta` pre-release. +- [ ] The PR from `stage` → `main` has been reviewed and testing passed. +- [ ] Preamble includes date, base version, and status. +- [ ] Overview contains a prose summary and a bullet list of all changes. +- [ ] Each bullet point has a corresponding Changes subsection. +- [ ] Tables or Mermaid diagrams are used where they add clarity. +- [ ] Each Changes subsection is separated by a horizontal rule. From d242faa5d35a9539415441d97633bbab9c8fcc2b Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Fri, 20 Mar 2026 14:19:59 +0100 Subject: [PATCH 05/13] Fix mobile styling issue on mobile (navbar and landing page) --- frontend/src/components/Navbar/Navbar.tsx | 2 +- frontend/src/routes/index.tsx | 24 +---------------------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/Navbar/Navbar.tsx b/frontend/src/components/Navbar/Navbar.tsx index 7dc4ffa..fae5192 100644 --- a/frontend/src/components/Navbar/Navbar.tsx +++ b/frontend/src/components/Navbar/Navbar.tsx @@ -25,7 +25,7 @@ export function Navbar() { Rustsystem -
+
{NAV_LINKS.map(({ to, label }) => ( ))}
- -
- Learn more: - - Guide - - · - - Cryptography - -
{/* Scroll cue */}
Date: Fri, 20 Mar 2026 14:20:34 +0100 Subject: [PATCH 06/13] Remove unnecessary variable and improve formatting --- .../src/signatures/e2e-tests/load.test.ts | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/frontend/src/signatures/e2e-tests/load.test.ts b/frontend/src/signatures/e2e-tests/load.test.ts index c088431..4265ed1 100644 --- a/frontend/src/signatures/e2e-tests/load.test.ts +++ b/frontend/src/signatures/e2e-tests/load.test.ts @@ -160,7 +160,10 @@ class ConcurrentClient { const adminSig = url.searchParams.get("admin_sig"); const admin_cred = adminMsg && adminSig - ? { msg: adminMsg.match(/.{2}/g)!.map((b) => parseInt(b, 16)), sig: adminSig } + ? { + msg: adminMsg.match(/.{2}/g)!.map((b) => parseInt(b, 16)), + sig: adminSig, + } : undefined; const serverRes = await this.req(`${BASE_URL}/api/login`, { @@ -323,7 +326,10 @@ describe.skipIf(!servicesReachable)("concurrent host invites", () => { const hosts = await Promise.all( Array.from({ length: N_MEETINGS }, async (_, i) => { const host = new ConcurrentClient(); - await host.createMeeting(`Concurrent Meeting ${i + 1}`, `Host ${i + 1}`); + await host.createMeeting( + `Concurrent Meeting ${i + 1}`, + `Host ${i + 1}`, + ); return host; }), ); @@ -331,8 +337,9 @@ describe.skipIf(!servicesReachable)("concurrent host invites", () => { // ── Every host adds all its voters simultaneously ──────────────────────── // Flatten to a single Promise.all so all 100 requests fire at once. const allTasks = hosts.flatMap((host, hi) => - Array.from({ length: VOTERS_PER_MEETING }, (_, vi) => () => - host.addVoter(`M${hi + 1} Voter ${vi + 1}`), + Array.from( + { length: VOTERS_PER_MEETING }, + (_, vi) => () => host.addVoter(`M${hi + 1} Voter ${vi + 1}`), ), ); const inviteLinks = await Promise.all(allTasks.map((fn) => fn())); @@ -461,19 +468,19 @@ describe.skipIf(!servicesReachable)("500-voter full system flow", () => { { name: "Round 1 — mixed", choices: [ - ...Array(125).fill(null), // blank - ...Array(150).fill([0]), // Option A - ...Array(125).fill([1]), // Option B - ...Array(100).fill([2]), // Option C + ...Array(125).fill(null), // blank + ...Array(150).fill([0]), // Option A + ...Array(125).fill([1]), // Option B + ...Array(100).fill([2]), // Option C ] as Array, expected: { blank: 125, A: 150, B: 125, C: 100 }, }, { name: "Round 2 — no blanks", choices: [ - ...Array(200).fill([0]), // Option A - ...Array(150).fill([1]), // Option B - ...Array(150).fill([2]), // Option C + ...Array(200).fill([0]), // Option A + ...Array(150).fill([1]), // Option B + ...Array(150).fill([2]), // Option C ] as Array, expected: { blank: 0, A: 200, B: 150, C: 150 }, }, @@ -538,10 +545,12 @@ describe.skipIf(!servicesReachable)("500-voter full system flow", () => { // Verified once before any round to keep the per-round hot path clean. // Starting a round is tested below; duplicate-start is checked BEFORE the // round goes live so it can never corrupt an active keypair. - const idleTally = await master["req"](`${BASE_URL}/api/host/tally`, { method: "POST" }); + const idleTally = await master["req"](`${BASE_URL}/api/host/tally`, { + method: "POST", + }); expect(idleTally.status).toBe(410); // VotingInactive — no active round yet - for (const [ri, round] of ROUNDS.entries()) { + for (const round of ROUNDS.values()) { // ── 6a. Start round ───────────────────────────────────────────────── await master.startVoteRound(round.name); @@ -551,7 +560,14 @@ describe.skipIf(!servicesReachable)("500-voter full system flow", () => { // never reaches trustauth and cannot replace the active keypair. const dupStart = await master["req"]( `${BASE_URL}/api/host/start-vote`, - { method: "POST", body: JSON.stringify({ name: "dup", shuffle: false, metadata: DEFAULT_METADATA }) }, + { + method: "POST", + body: JSON.stringify({ + name: "dup", + shuffle: false, + metadata: DEFAULT_METADATA, + }), + }, ); expect(dupStart.status).toBe(409); @@ -578,30 +594,33 @@ describe.skipIf(!servicesReachable)("500-voter full system flow", () => { const result = await master.tally(); expect(result.blank).toBe(round.expected.blank); - expect(result.score[DEFAULT_METADATA.candidates[0]]).toBe(round.expected.A); - expect(result.score[DEFAULT_METADATA.candidates[1]]).toBe(round.expected.B); - expect(result.score[DEFAULT_METADATA.candidates[2]]).toBe(round.expected.C); + expect(result.score[DEFAULT_METADATA.candidates[0]]).toBe( + round.expected.A, + ); + expect(result.score[DEFAULT_METADATA.candidates[1]]).toBe( + round.expected.B, + ); + expect(result.score[DEFAULT_METADATA.candidates[2]]).toBe( + round.expected.C, + ); const total = - result.blank + - Object.values(result.score).reduce((a, b) => a + b, 0); + result.blank + Object.values(result.score).reduce((a, b) => a + b, 0); expect(total).toBe(N); // Tallying again from Tally state must be rejected (410). - const dupTally = await master["req"]( - `${BASE_URL}/api/host/tally`, - { method: "POST" }, - ); + const dupTally = await master["req"](`${BASE_URL}/api/host/tally`, { + method: "POST", + }); expect(dupTally.status).toBe(410); // ── 6f. Reset state machine for the next round ─────────────────────── await master.endVoteRound(); // Tallying after reset (Idle state) must also be rejected (410). - const staleTally = await master["req"]( - `${BASE_URL}/api/host/tally`, - { method: "POST" }, - ); + const staleTally = await master["req"](`${BASE_URL}/api/host/tally`, { + method: "POST", + }); expect(staleTally.status).toBe(410); } From 3f011cb97619d563504c07977fb4242d1740b67c Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Fri, 20 Mar 2026 15:01:56 +0100 Subject: [PATCH 07/13] Update documentation on how to use the APIHandler trait --- rustsystem-core/src/lib.rs | 85 +++++++++++++------------------------- 1 file changed, 28 insertions(+), 57 deletions(-) diff --git a/rustsystem-core/src/lib.rs b/rustsystem-core/src/lib.rs index e0f951a..9ee0b86 100644 --- a/rustsystem-core/src/lib.rs +++ b/rustsystem-core/src/lib.rs @@ -264,87 +264,58 @@ impl APIErrorFinal { pub trait APIEndpointError: Into {} -/// Defines one API route. Implementing this trait for an empty struct will requires the `handler` -/// method that can be used as a [`Handler`] for a [`MethodRouter`]. +/// Defines one API route. Implement this trait on an empty struct, then register it with +/// [`add_handler`]. /// -/// The `State` should be set to the type expected when calling upon the server State. +/// - `State` — the Axum application state type. +/// - `Request` — any type implementing [`FromRequest`]. Typically a tuple of extractors +/// (e.g. `(State, Json)`). +/// - `SuccessResponse` — the response body type on success. Use `()` for no body. /// -/// The `Request` type can be any type that implements [`FromRequest`]. The simplest case is for -/// `Request` to be a tuple of the parameters that would form the parameters of the equivalent -/// handler function. +/// Errors are always returned as [`APIError`]. The provided `handler` method wraps `route`, +/// converts any `APIError` into a JSON [`APIErrorFinal`] response, and attaches the correct +/// HTTP status code. Implementors only need to write `route`. /// -/// The `SuccessResponse` is the type that forms the response in the successful case (i.e. the -/// expected success structure) -/// -/// Equivalently, the `ErrorResponse` is the structure of the unsuccessful case. -/// -/// Note that the StatusCode should not be included in either `SuccessResponse` or `ErrorResponse`. -/// rather, the StatusCode is enforced in the `APIResult` and `APIResponse` return types. A -/// response cannot be sent without a StatusCode. +/// Route registration is done via [`add_handler`], which reads `METHOD` and `PATH` to call +/// the appropriate Axum routing method automatically. /// /// Example: -/// ```rust -/// +/// ```rust,ignore +/// use serde::{Deserialize, Serialize}; +/// use axum::{Json, extract::State, http::StatusCode}; +/// use rustsystem_core::{APIError, APIErrorCode, APIHandler, Method, add_handler}; /// /// #[derive(Deserialize)] /// struct ExampleRequestBody { /// name: String, -/// age: u8, -/// id: usize, -/// } -/// -/// #[derive(Serialize)] -/// enum ExampleError { -/// SomethingFailed, -/// ServerSadness { tears: u8 }, -/// Other, /// } /// /// #[derive(Serialize)] /// struct ExampleSuccess { -/// epoch: u64, -/// reference: String, +/// greeting: String, /// } /// /// struct ExampleHandler; +/// +/// #[async_trait::async_trait] /// impl APIHandler for ExampleHandler { /// type State = AppState; -/// // Any type that can be found in `FromRequestParts` can be included in the Request -/// type Request = ( -/// CookieJar, -/// State, -/// AuthUser, -/// Json, -/// ); -/// -/// // Note that the `SuccessResponse` can also be unit type if there should be no response body +/// type Request = (State, Json); +/// // Use () if there is no response body /// type SuccessResponse = Json; -/// type ErrorResponse = Json; /// -/// async fn handler( -/// request: Self::Request, -/// ) -> APIResponse { -/// // Destructure, just like you would in a handler function -/// let ( -/// jar, -/// State(state), -/// AuthUser { -/// uuid, -/// muid, -/// is_host, -/// }, -/// Json(body), -/// ) = request; +/// const METHOD: Method = Method::Post; +/// const PATH: &'static str = "/example"; +/// const SUCCESS_CODE: StatusCode = StatusCode::OK; /// -/// // Do some stuff -/// unimplemented!() +/// async fn route(request: Self::Request) -> Result { +/// let (State(state), Json(body)) = request; +/// Ok(Json(ExampleSuccess { greeting: format!("Hello, {}!", body.name) })) /// } /// } /// -/// fn main() { -/// // The `handler` function can now be used in the router -/// Router::new().route("/example", post(ExampleHandler::handler)); -/// } +/// // Register in a router: +/// let router = add_handler::(Router::new()); /// ``` #[async_trait] From ae8c66aeebef9e5ef550e9309024a555a68de908 Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Fri, 20 Mar 2026 15:02:38 +0100 Subject: [PATCH 08/13] Add CI pipeline for stage --- .github/workflows/ci.yml | 76 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..820af48 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [stage] + pull_request: + branches: [stage] + +env: + API_ENDPOINT_SERVER: "http://localhost:1443" + API_ENDPOINT_TRUSTAUTH_TO_SERVER: "https://localhost:1444" + API_ENDPOINT_TRUSTAUTH: "http://localhost:2443" + API_ENDPOINT_SERVER_TO_TRUSTAUTH: "https://localhost:2444" + +jobs: + frontend: + name: Frontend tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Generate mTLS certificates + working-directory: mtls + run: bash mkcerts.sh dev + + - name: Build backend binaries + run: cargo build --bin rustsystem-server --bin rustsystem-trustauth + + - name: Start backend services + run: | + ./target/debug/rustsystem-server & + ./target/debug/rustsystem-trustauth & + + - name: Wait for services to be ready + run: | + until nc -z localhost 1443 2>/dev/null; do sleep 1; done + until nc -z localhost 2443 2>/dev/null; do sleep 1; done + + - uses: pnpm/action-setup@v4 + with: + version: latest + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Run tests + working-directory: frontend + run: pnpm test + + backend: + name: Backend tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Generate mTLS certificates + working-directory: mtls + run: bash mkcerts.sh dev + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test From 5e6b1a64957609e4bc5b6b2d5bf4615fd29088ae Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Fri, 20 Mar 2026 15:38:23 +0100 Subject: [PATCH 09/13] Change wording in development flow in regards to versioning --- CONTRIBUTE.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index ce4a936..5c77e41 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -54,11 +54,13 @@ flowchart LR Versions follow **MAJOR.MINOR.PATCH**, optionally suffixed with `-beta` for pre-releases. -| Component | When to increment | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **MAJOR** | Complete redesign of the system — e.g. a full frontend and/or backend rewrite, a fundamental change to the cryptographic protocol, or a structural reorganisation so large that upgrading requires rebuilding the deployment from scratch. | -| **MINOR** | Breaking or significant changes — endpoint changes or additions, workflow changes, logic changes, large UI changes. | -| **PATCH** | Small non-breaking changes — documentation fixes, small visual changes, small backend changes that do not affect the API or workflow. | +MAJOR and MINOR versions are **milestone-based**: each number corresponds to a defined project milestone. PATCH versions are used for incremental development updates made while working towards the next milestone. + +| Component | When to increment | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **MAJOR** | Reaching a top-level milestone that represents a new generation of the project — e.g. a complete rewrite, a fundamental protocol change, or a shift in overall project direction so significant that it warrants a new major milestone series. | +| **MINOR** | Reaching a planned minor milestone — a defined set of features, goals, or deliverables that has been completed. What constitutes a milestone should be agreed upon before development begins (tracked on github milestones). | +| **PATCH** | Development updates made while working towards the next milestone — bug fixes, partial feature progress, documentation changes, small visual tweaks, or any change that does not yet complete a milestone. | ### Beta Pre-releases @@ -192,7 +194,7 @@ Use a Mermaid diagram when the relationship between steps, services, or states i ## Checklist Before Publishing a Release -- [ ] Version number follows MAJOR.MINOR.PATCH rules above. +- [ ] Version number follows MAJOR.MINOR.PATCH rules above (MAJOR/MINOR = milestone reached, PATCH = development update). - [ ] If MINOR or MAJOR changed, this is a `-beta` pre-release. - [ ] The PR from `stage` → `main` has been reviewed and testing passed. - [ ] Preamble includes date, base version, and status. From b3f535bfdb363d24d1dc1964163c8b4aed64e56c Mon Sep 17 00:00:00 2001 From: Felix Hellborg <61821031+HellFelix@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:32:57 +0100 Subject: [PATCH 10/13] Fix invite-watch SSE to broadcast voter name and not auto-dismiss QR (#46) --- frontend/src/routes/admin.tsx | 22 ++++--------------- .../src/api/host/invite_watch.rs | 10 +++------ .../src/api/host/start_invite.rs | 3 +-- rustsystem-server/src/api/login.rs | 4 ++-- rustsystem-server/src/invite_auth.rs | 10 ++++----- 5 files changed, 15 insertions(+), 34 deletions(-) diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 7dc7e36..aac7b4e 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -179,10 +179,6 @@ function QRPanel({ } >
- - Waiting for {result.voterName} to scan the QR code… - - {/* QR code — white background ensures readability regardless of theme */}
( null, ); - // Ref so the SSE closure always reads the current qrInfo without re-subscribing. - const qrInfoRef = useRef(qrInfo); const [loadError, setLoadError] = useState(null); - useEffect(() => { - qrInfoRef.current = qrInfo; - }, [qrInfo]); - const reloadVoters = useCallback(async () => { try { const list = await fetchVoterList(); @@ -1050,13 +1040,9 @@ function Admin() { withCredentials: true, }); es.onmessage = (e) => { - const raw = (e.data as string).replace(/^"|"$/g, ""); - if (raw === "Ready") { - const name = qrInfoRef.current?.voterName ?? null; - setQrInfo(null); - setJoinedVoterName(name); - reloadVoters(); - } + const name = e.data as string; + setJoinedVoterName(name); + reloadVoters(); }; es.onerror = () => console.warn("invite-watch SSE disconnected"); return () => es.close(); @@ -1283,7 +1269,7 @@ function Admin() {
- {joinedVoterName && !qrInfo && ( + {joinedVoterName && ( , fn(bool) -> Option>>>; + Sse>, fn(Option) -> Option>>>; async fn route(request: Self::Request) -> Result { let InviteWatchRequest { @@ -36,12 +36,8 @@ impl APIHandler for InviteWatch { state: State(state), } = request; - let upon_event = |new_state| { - if new_state { - Some(Ok::(Event::default().data("Ready"))) - } else { - Some(Ok::(Event::default().data("Wait"))) - } + let upon_event = |new_state: Option| { + new_state.map(|name| Ok::(Event::default().data(name))) }; let meeting = state.get_meeting(auth.muuid).await?; diff --git a/rustsystem-server/src/api/host/start_invite.rs b/rustsystem-server/src/api/host/start_invite.rs index 441192d..dbc3901 100644 --- a/rustsystem-server/src/api/host/start_invite.rs +++ b/rustsystem-server/src/api/host/start_invite.rs @@ -33,8 +33,7 @@ impl APIHandler for StartInvite { state: State(state), } = request; - let meeting = state.get_meeting(auth.muuid).await?; - meeting.invite_auth.write().await.set_state(true); + state.get_meeting(auth.muuid).await?; info!(muuid = %auth.muuid, "Invite watch opened"); diff --git a/rustsystem-server/src/api/login.rs b/rustsystem-server/src/api/login.rs index 0edbaca..b5effae 100644 --- a/rustsystem-server/src/api/login.rs +++ b/rustsystem-server/src/api/login.rs @@ -60,8 +60,8 @@ impl APIHandler for Login { voter.name.clone() }; // voters write guard released - // Signal the invite watcher. - meeting.invite_auth.write().await.set_state(true); + // Signal the invite watcher with the voter's name. + meeting.invite_auth.write().await.notify_login(voter_name.clone()); // Validate optional admin credentials. let is_host = if let Some(admin_cred) = body.admin_cred { diff --git a/rustsystem-server/src/invite_auth.rs b/rustsystem-server/src/invite_auth.rs index 86a5c36..e1fb62f 100644 --- a/rustsystem-server/src/invite_auth.rs +++ b/rustsystem-server/src/invite_auth.rs @@ -2,22 +2,22 @@ use tokio::sync::watch::{Receiver, Sender}; use tracing::error; pub struct InviteAuthority { - state_tx: Sender, + state_tx: Sender>, } impl InviteAuthority { pub fn new() -> Self { Self { - state_tx: Sender::new(false), + state_tx: Sender::new(None), } } - pub fn set_state(&mut self, new_state: bool) { - if let Err(e) = self.state_tx.send(new_state) { + pub fn notify_login(&mut self, voter_name: String) { + if let Err(e) = self.state_tx.send(Some(voter_name)) { error!("{e}"); } } - pub fn new_watcher(&self) -> Receiver { + pub fn new_watcher(&self) -> Receiver> { self.state_tx.subscribe() } } From a45bf77c5fa6d8e8c5b65b290d7f4968ced29515 Mon Sep 17 00:00:00 2001 From: Felix Hellborg <61821031+HellFelix@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:01:10 +0200 Subject: [PATCH 11/13] Allow hosts to remove other hosts, add session guard to admin page - The voter list remove button now shows for all voters except the currently logged-in user (previously hidden for all hosts). Hosts can now be kicked by other hosts, but cannot kick themselves. - getSessionIds now throws on non-OK responses, consistent with other API helpers. - The admin page gains the same sessionValid session-check pattern used on the meeting page: a refreshSession callback polls /api/common/vote-progress every 10 seconds and on 401 sets the session invalid. The initial data load also detects 401 immediately. When the session is invalid, a "Not an administrator" panel is shown instead of the normal admin UI which is necessary because a host can now be removed mid-session by another host. --- frontend/src/routes/admin.tsx | 67 ++++++++++++++++++++++++-- frontend/src/signatures/voteSession.ts | 1 + 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index aac7b4e..24152c7 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -10,11 +10,13 @@ import { Badge } from "@/components/Badge/Badge"; import { Panel } from "@/components/Panel/Panel"; import { VotePanel, type VoteState } from "@/components/VotePanel/VotePanel"; import { + apiFetch, apiUrl, startVoteRound, tally as tallyVote, getTally, endVoteRound, + getSessionIds, type TallyResult, } from "@/signatures/voteSession"; import { @@ -224,12 +226,14 @@ function QRPanel({ function VoterListPanel({ voters, loading, + selfUuuid, onRemove, onRemoveAll, onReload, }: { voters: VoterInfo[]; loading: boolean; + selfUuuid: string | null; onRemove: (uuid: string) => Promise; onRemoveAll: () => Promise; onReload: () => void; @@ -365,7 +369,7 @@ function VoterListPanel({ )} - {!v.is_host && ( + {v.uuid !== selfUuuid && (