From 1fe3df7962d99b2177ce71750f232bc4e549fb1a Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:43:30 +0200 Subject: [PATCH] test(b2-1): capture the epoch-1 protocol fixtures and retire S-15 (#1435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(protocol): retire reserved voice_speakers and member_leave (S-15) Neither type was ever emitted by the server; B2-1 clears them from the schema before the epoch-1 wire fixtures are captured, so the frozen epoch does not carry two dead message types. Client: dropped the dead `ws.on(MEMBER_LEAVE)` / `ws.on(VOICE_SPEAKERS)` dispatcher handlers, the `MemberLeavePayload` type and both `ServerMessage` union members, and the tests that only exercised those WS paths. `removeMember` (member_ban) and `setSpeakers` (LiveKit ActiveSpeakers) stay live and keep their direct unit tests. * test(ws): capture the epoch-1 wire fixtures alpha.4 is the last client on the pre-epoch wire and B2-2 adds a protocol epoch to the auth handshake next, so record what epoch 1 actually looks like while it is still observable. TestEpoch1Fixtures drives eleven journeys through the ws package's in-process hub harness (full migrations, real hub, httptest WebSocket server) and compares each journey's per-connection frame sequence with a transcript under protocol/fixtures/epoch-1/: - fresh-connect, auth-failure, ping - chat-send-fanout, chat-edit-delete, reaction-add-remove - typing, mark-read, dm-send - resume-replay (last_seq + buffer-tier replay burst) - voice-join-e2ee-leave (join, both voice_state forms, announce and offer relay, leave) Volatile values are replaced by typed placeholders before both writing and comparison -- any key that is id/seq/last_seq, ends in _id (except channel_id and role_id) or _at, is timestamp/ts/last_seen, or contains token, becomes "" so a field that changes type is still a diff, while everything else is compared verbatim. Regenerate with: go test ./ws -run TestEpoch1Fixtures -update Co-Authored-By: Claude Fable 5 * test(client): pin the epoch-1 auth frame contract Co-Authored-By: Claude Fable 5 * test(updater): pin the epoch-1 manifest and client-update shapes * test(ws): harden the epoch-1 fixtures against trailing frames and absent optionals Every journey now ends with the ping/pong barrier on each recorded connection, so a frame the server emits after the last read fails as `expected "pong", got "X"` instead of going unrecorded; auth-failure asserts the StatusPolicyViolation close instead, its socket being gone. alice carries a display name, avatar, about text, custom status, identity public key and an announce signature (bob carries none), so every optional field is frozen in its present form as well as its absent one — a rename or a retype of display_name or identity_public_key now moves a fixture. The typing journey focuses the channel on "a" before typing: without the subscription registerNow only makes for a focused client, its ping/pong proved nothing about excludeUserID. Comment fixes: the escaped placeholder form MarshalIndent would write, the real (headroom) reason for the raised read limit, a note that bare id and active_channel_id are normalised by design, and a .prettierignore line saying these fixtures are verified by the Go comparison, not by git diff. Co-Authored-By: Claude Fable 5 * docs(protocol): document the epoch-1 fixtures * docs(protocol): match the epoch-1 wire where the fixtures contradicted the doc - voice_state: note the unsequenced relay-to-joiner form (table row + section) - auth_error: correct the example message and list the real rejection messages - member_join/presence connect example: add the seq both frames actually carry - chat_message.user: document display_name - voice_join reply order: state that the joiner's own voice_state broadcast is not ordered against the other three frames * test(ws): freeze the null forms of auth_ok and member_join user fields buildAuthOK emits display_name/about/custom_status/avatar as always-present nulls, but only alice — who has all four set — ever authenticated on a recorded connection, so the fixtures froze those fields in their populated form alone. A rename, a retype or a dropped null would have moved nothing, on the very frame B2-2 edits. fresh-connect now records bob's handshake too, on a second connection: his auth_ok carries the four nulls, his member_join carries avatar null with display_name and identity_public_key omitted, and alice — idle by then, so her reads stay in hub order — records the same pair as an already-connected observer sees it. In voice-join-e2ee-leave bob answers alice's signed announce with a legacy unsigned one, which freezes the absent form of signature next to her present one. The auth frames the test writes now carry the correlation id the real client stamps on every frame (ws.ts send()); normalisation renders it . expectClosed also asserts the close reason ("authentication failed"), not just code 1008 — HP-2 asks for both. Comment precision, no behaviour change: the barrier guarantee now states that pong may overtake a pending LOW-priority frame (writePump) and that no journey is affected because every barrier is sent on an idle connection; the ping-budget ceiling is six connections, not four; the typing journey cites handleChannelFocusV2 rather than registerNow as the subscribe site; resume-replay's b barrier explains why moving it past the resume would be a flake, not a fix; and the header notes that normalisation hides that chat_send_ok.id echoes the request id. The client contract test's cited range for ws.ts's send() call is 441-453, not 441-454. Regenerate with: go test ./ws -run TestEpoch1Fixtures -update Co-Authored-By: Claude Fable 5 * docs(protocol): shape-not-value fixture rule, voice_max_video default, auth close code The fixture rule read "a fixture may only change with an epoch bump", which is false for the seeded values the transcripts record verbatim: role permission masks and colours, motd, server_name, voice_max_video, the voice_config preset. A migration that changes a default mask diffs fresh-connect.json, and the README told the author to revert a change that never touched the wire. Split the rule along shape versus value. A key set, a JSON type, a key appearing or disappearing, or per-connection frame order is a protocol change and earns fixtures/epoch-/. A seeded default value is a seed change: regenerate in the same PR and read the diff frame by frame. Normalising those values is explicitly not the answer — a placeholder over a mask or over an enum such as voice_config.threshold_mode would hide the drift the fixtures exist to catch. Two wire facts corrected against the fixtures: - voice_max_video on an unconfigured channel is 25, not 0 (migration 004 is DEFAULT 25); the doc listed it among the zero values. - auth_error is followed by a close with code 1008 (policy violation) and reason "authentication failed" (serve.go:128), which the doc left as "closes the connection". Also: voice_speakers moves from discord-parity's "still dead" list to "came off the list" — it was retired earlier on this branch. And Kick says sessions are revoked and sockets drop on the next sweep, which is what ForceLogout does (moderation.go:236); it does not cut sockets. Co-Authored-By: Claude Fable 5 * docs(protocol): fixture rule covers enum vocabularies; header comment precision Re-check minors from the whole-branch review: a fixed-vocabulary value the client switches on is shape, not a seeded value; drop the false 'new default channel' clause (the seed asserts channel ids); the low-priority frame is presence_update, not connect presence. * docs(plans): record B2-1 evidence for HP-2 (PR #1435, fixture commit SHAs) * docs(protocol): additive changes stay within an epoch; bump only for what old clients cannot process Codex review on #1435: B2-2 keeps protocol_epoch = 1 while adding auth/ready/auth_error fields, which the previous wording would have called a break to revert. An epoch is a compatibility boundary, not a snapshot: additive keys regenerate in the same PR and are documented; removals, renames, retypes, dropped frames and reordering bump the epoch. The plan's B2-1 evidence records the refinement and hands B2-2/B2-4 the open questions (additive-tolerant replay of the epoch-1 transcript; epoch 0 vs 1 naming). * test(client): compare auth-frame key sets order-independently Codex review on #1435: Object.keys preserves insertion order, so a harmless property reorder in ws.ts would fail the pin. Key order has no wire meaning; the Go fixtures already compare with sorted keys. Sort both sides. --------- Co-authored-by: Claude Fable 5 --- .prettierignore | 4 + Client/src/lib/dispatcher.ts | 14 - Client/src/lib/protocolTypes.ts | 2 - Client/src/lib/types.ts | 10 +- Client/src/stores/members.store.ts | 2 +- Client/src/stores/voice.store.ts | 6 +- Client/tests/contract/ws-auth-frame.test.ts | 143 ++ Client/tests/e2e/voice-lifecycle.spec.ts | 62 +- Client/tests/integration/stores.test.ts | 73 +- Client/tests/unit/dispatcher.test.ts | 49 - Client/tests/unit/types.test.ts | 24 +- Client/tests/unit/voice.store.test.ts | 6 +- Server/api/client_update_test.go | 70 + Server/updater/release_manifest_test.go | 74 ++ Server/ws/message_types.go | 2 - Server/ws/protocol_epoch1_contract_test.go | 1159 +++++++++++++++++ docs/architecture/ux/README.md | 44 +- docs/architecture/ux/channels-members-dms.md | 16 +- docs/architecture/ux/settings-and-admin.md | 20 +- docs/architecture/ux/voice-and-e2ee.md | 3 +- docs/architecture/voice-e2ee.md | 2 +- docs/audit-2026-08-19.md | 4 +- .../b2-protocol-trust-compat-2026-08-28.md | 38 + docs/plans/discord-parity.md | 5 +- docs/protocol.md | 77 +- protocol/README.md | 58 +- protocol/fixtures/epoch-1/auth-failure.json | 27 + .../fixtures/epoch-1/chat-edit-delete.json | 110 ++ .../fixtures/epoch-1/chat-send-fanout.json | 111 ++ protocol/fixtures/epoch-1/dm-send.json | 111 ++ protocol/fixtures/epoch-1/fresh-connect.json | 381 ++++++ protocol/fixtures/epoch-1/mark-read.json | 29 + protocol/fixtures/epoch-1/ping.json | 33 + .../fixtures/epoch-1/reaction-add-remove.json | 113 ++ protocol/fixtures/epoch-1/resume-replay.json | 178 +++ protocol/fixtures/epoch-1/typing.json | 55 + .../epoch-1/voice-join-e2ee-leave.json | 256 ++++ protocol/schema.json | 2 - 38 files changed, 3064 insertions(+), 309 deletions(-) create mode 100644 Client/tests/contract/ws-auth-frame.test.ts create mode 100644 Server/ws/protocol_epoch1_contract_test.go create mode 100644 protocol/fixtures/epoch-1/auth-failure.json create mode 100644 protocol/fixtures/epoch-1/chat-edit-delete.json create mode 100644 protocol/fixtures/epoch-1/chat-send-fanout.json create mode 100644 protocol/fixtures/epoch-1/dm-send.json create mode 100644 protocol/fixtures/epoch-1/fresh-connect.json create mode 100644 protocol/fixtures/epoch-1/mark-read.json create mode 100644 protocol/fixtures/epoch-1/ping.json create mode 100644 protocol/fixtures/epoch-1/reaction-add-remove.json create mode 100644 protocol/fixtures/epoch-1/resume-replay.json create mode 100644 protocol/fixtures/epoch-1/typing.json create mode 100644 protocol/fixtures/epoch-1/voice-join-e2ee-leave.json diff --git a/.prettierignore b/.prettierignore index 693e2a16..1f4fa1a2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,10 @@ # Generated, verified by `git diff --exit-code` after regeneration. Server/db/dbgen/ Client/src/lib/protocolTypes.ts +# Frozen wire records, written by `go test ./ws -run TestEpoch1Fixtures -update`. +# Unlike the two above, drift is caught by that test's own frame comparison, not +# by `git diff --exit-code`. +protocol/fixtures/ # Dated point-in-time snapshots. scripts/check-doc-counts.mjs already treats diff --git a/Client/src/lib/dispatcher.ts b/Client/src/lib/dispatcher.ts index 5bf70cad..6c880fd5 100644 --- a/Client/src/lib/dispatcher.ts +++ b/Client/src/lib/dispatcher.ts @@ -49,7 +49,6 @@ import { updateVoiceUserProfile, removeVoiceUser, setVoiceConfig, - setSpeakers, joinVoiceChannel, leaveVoiceChannel, } from "@stores/voice.store"; @@ -871,13 +870,6 @@ export function wireDispatcher( }), ); - unsubs.push( - ws.on(S.MEMBER_LEAVE, (payload) => { - log.info("Member left", { userId: payload.user_id }); - removeMember(payload.user_id); - }), - ); - unsubs.push( ws.on(S.MEMBER_BAN, (payload) => { log.info("Member banned", { userId: payload.user_id }); @@ -1098,12 +1090,6 @@ export function wireDispatcher( }), ); - unsubs.push( - ws.on(S.VOICE_SPEAKERS, (payload) => { - setSpeakers(payload); - }), - ); - unsubs.push( ws.on(S.VOICE_TOKEN, (payload) => { void livekitSession().then(({ handleVoiceToken }) => diff --git a/Client/src/lib/protocolTypes.ts b/Client/src/lib/protocolTypes.ts index cc877e63..e4741c1f 100644 --- a/Client/src/lib/protocolTypes.ts +++ b/Client/src/lib/protocolTypes.ts @@ -29,12 +29,10 @@ export const ServerMessageType = { VOICE_STATE: "voice_state", VOICE_CONFIG: "voice_config", VOICE_TOKEN: "voice_token", - VOICE_SPEAKERS: "voice_speakers", VOICE_LEAVE: "voice_leave", // broadcast (same string as client msg) VOICE_MOVED: "voice_moved", VOICE_DISCONNECTED: "voice_disconnected", MEMBER_JOIN: "member_join", - MEMBER_LEAVE: "member_leave", MEMBER_UPDATE: "member_update", USER_UPDATE: "user_update", MEMBER_BAN: "member_ban", diff --git a/Client/src/lib/types.ts b/Client/src/lib/types.ts index aa47605c..3c79c579 100644 --- a/Client/src/lib/types.ts +++ b/Client/src/lib/types.ts @@ -464,7 +464,9 @@ export interface VoiceConfigPayload { readonly max_users: number; } -/** CRITICAL: uses threshold_mode, NOT mode. */ +/** Argument shape for `voice.store.setSpeakers` — fed by LiveKit's + * ActiveSpeakersChanged, not by a wire message. + * CRITICAL: uses threshold_mode, NOT mode. */ export interface VoiceSpeakersPayload { readonly channel_id: number; readonly speakers: readonly number[]; @@ -508,10 +510,6 @@ export interface MemberJoinPayload { readonly status?: UserStatus; } -export interface MemberLeavePayload { - readonly user_id: number; -} - /** * Full role list after any role mutation. The server sends the whole list * rather than a delta, so the store is replaced wholesale — a dropped @@ -765,14 +763,12 @@ export type ServerMessage = | (WsEnvelope & { readonly type: "voice_state" }) | (WsEnvelope & { readonly type: "voice_leave" }) | (WsEnvelope & { readonly type: "voice_config" }) - | (WsEnvelope & { readonly type: "voice_speakers" }) | (WsEnvelope & { readonly type: "voice_token" }) | (WsEnvelope & { readonly type: "voice_moved" }) | (WsEnvelope & { readonly type: "voice_disconnected" }) | (WsEnvelope & { readonly type: "voice_e2ee_announce" }) | (WsEnvelope & { readonly type: "voice_e2ee_offer" }) | (WsEnvelope & { readonly type: "member_join" }) - | (WsEnvelope & { readonly type: "member_leave" }) | (WsEnvelope & { readonly type: "member_update" }) | (WsEnvelope & { readonly type: "user_update" }) | (WsEnvelope & { readonly type: "member_ban" }) diff --git a/Client/src/stores/members.store.ts b/Client/src/stores/members.store.ts index 637b84db..db44671c 100644 --- a/Client/src/stores/members.store.ts +++ b/Client/src/stores/members.store.ts @@ -105,7 +105,7 @@ export function addMember(payload: MemberJoinPayload): void { }); } -/** Remove a member from a member_leave event. */ +/** Remove a member from a member_ban event. */ export function removeMember(userId: number): void { membersStore.setState((prev) => { const next = new Map(prev.members); diff --git a/Client/src/stores/voice.store.ts b/Client/src/stores/voice.store.ts index 0bf3b93e..4c6a4bda 100644 --- a/Client/src/stores/voice.store.ts +++ b/Client/src/stores/voice.store.ts @@ -477,9 +477,9 @@ export function setVoiceConfig(payload: VoiceConfigPayload): void { }); } -/** Update speaking state for users from a voice_speakers event or - * LiveKit's ActiveSpeakersChanged. Updates ALL users including local - * (LiveKit is now the sole authority for speaking detection). */ +/** Update speaking state for users from LiveKit's ActiveSpeakersChanged. + * Updates ALL users including local (LiveKit is the sole authority for + * speaking detection). */ export function setSpeakers(payload: VoiceSpeakersPayload): void { voiceStore.setState((prev) => { const existingChannel = prev.voiceUsers.get(payload.channel_id); diff --git a/Client/tests/contract/ws-auth-frame.test.ts b/Client/tests/contract/ws-auth-frame.test.ts new file mode 100644 index 00000000..dbb0ba6d --- /dev/null +++ b/Client/tests/contract/ws-auth-frame.test.ts @@ -0,0 +1,143 @@ +// CONTRACT TEST. Pins the exact key set of the `auth` frame that +// Client/src/lib/ws.ts sends as the first message after the WebSocket opens +// (ws.ts:441-453) -- the client side of the same wire contract a sibling Go +// test freezes for the server. B2-2 adds a protocol-epoch field to this +// frame; that change MUST fail the assertions below until B2-2 deliberately +// extends this test's key sets. Extend this file, do not replace or delete +// it. +// +// Assertions compare exact key sets (sorted Object.keys -- key order has no +// wire meaning), never toHaveProperty, so an unexpected added key fails just +// as loudly as a missing one. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// vi.mock is hoisted per file; the factories resolve to the shared handles +// exported from ../unit/helpers/ws-mocks (see that module's doc comment -- +// it is shared across all ws-*.test.ts files, this one included). +vi.mock("@tauri-apps/api/core", async () => ({ + invoke: (await import("../unit/helpers/ws-mocks")).mockInvoke, +})); + +vi.mock("@tauri-apps/api/event", async () => ({ + listen: (await import("../unit/helpers/ws-mocks")).mockListen, +})); + +import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "../unit/helpers/ws-mocks"; +import { createWsClient, setActiveChannelProvider } from "../../src/lib/ws"; + +/** Parses the most recently sent `auth` frame (envelope + payload) from ws_send. */ +function getAuthFrame(): { type: string; payload: Record } { + const authCall = mockInvoke.mock.calls.find( + (c) => + c[0] === "ws_send" && + typeof c[1]?.message === "string" && + (c[1].message as string).includes('"type":"auth"'), + ); + expect(authCall).toBeDefined(); + return JSON.parse((authCall![1] as { message: string }).message) as { + type: string; + payload: Record; + }; +} + +describe("contract: auth frame key set (epoch 1)", () => { + let client: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockClear(); + eventHandlers.clear(); + // activeChannelProvider is a module-level singleton (registered once at + // app bootstrap in dispatcher.ts) -- reset it so state doesn't leak + // across tests/files. + setActiveChannelProvider(null); + client = createWsClient(); + }); + + afterEach(() => { + client.disconnect(); + setActiveChannelProvider(null); + vi.useRealTimers(); + }); + + it("fresh connect: envelope keys are exactly [type, payload, id], payload keys exactly [token, last_seq]", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + const frame = getAuthFrame(); + // send() (ws.ts:631-637) wraps every outgoing message with a correlation + // `id` via `{ ...msg, id }` -- that's a generic per-send addition, not + // part of the auth-specific payload contract, but it IS part of what + // actually goes over the wire, so the envelope pin has three keys, not + // the two the auth message literal at ws.ts:446-453 has on its own. + expect(Object.keys(frame).sort()).toEqual(["id", "payload", "type"]); + expect(frame.type).toBe("auth"); + expect(Object.keys(frame.payload).sort()).toEqual(["last_seq", "token"]); + expect(frame.payload.token).toBe("t"); + expect(frame.payload.last_seq).toBe(0); + }); + + it("resume with a registered active-channel provider: payload keys exactly [token, last_seq, active_channel_id]", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 7, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + setActiveChannelProvider(() => 42); + + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + const frame = getAuthFrame(); + expect(Object.keys(frame.payload).sort()).toEqual(["active_channel_id", "last_seq", "token"]); + expect(frame.payload.last_seq).toBe(7); + expect(frame.payload.active_channel_id).toBe(42); + }); + + it("resume without a provider registered: payload keys stay exactly [token, last_seq]", async () => { + client.connect({ host: "localhost:8443", token: "t" }); + await vi.advanceTimersByTimeAsync(10); + emitTauriEvent("ws-state", "open"); + + emitTauriEvent( + "ws-message", + JSON.stringify({ + type: "auth_ok", + seq: 3, + payload: { + user: { id: 1, username: "a", avatar: null, role: "admin" }, + server_name: "S", + motd: "", + }, + }), + ); + + // No setActiveChannelProvider call -- stays null from beforeEach reset. + emitTauriEvent("ws-state", "closed"); + mockInvoke.mockClear(); + await vi.advanceTimersByTimeAsync(1100); + emitTauriEvent("ws-state", "open"); + + const frame = getAuthFrame(); + expect(Object.keys(frame.payload).sort()).toEqual(["last_seq", "token"]); + expect(frame.payload.last_seq).toBe(3); + }); +}); diff --git a/Client/tests/e2e/voice-lifecycle.spec.ts b/Client/tests/e2e/voice-lifecycle.spec.ts index eb91858f..fba48f4e 100644 --- a/Client/tests/e2e/voice-lifecycle.spec.ts +++ b/Client/tests/e2e/voice-lifecycle.spec.ts @@ -4,7 +4,7 @@ * These tests use the existing Tauri mock infrastructure to simulate: * - WebSocket voice_state and voice_leave events * - Voice channel UI (sidebar voice users, voice widget) - * - Speaker indicators, connection quality, listen-only mode + * - Connection quality, listen-only mode * * NOTE: These tests do NOT exercise real LiveKit/WebRTC connections. * Real voice E2E requires the native test infrastructure (Tauri exe + LiveKit binary). @@ -68,42 +68,6 @@ test.describe("Voice lifecycle", () => { // Should now have 1 user await expect(page.locator(".voice-user-item")).toHaveCount(1, { timeout: 5000 }); }); - - test("speaker indicator updates on voice_speakers event", async ({ page }) => { - // Wait for voice users to render - await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 }); - - // Emit speakers event — user 3 (in channel 10 per the ready payload) speaks - await emitWsMessage(page, { - type: "voice_speakers", - payload: { - channel_id: 10, - speakers: [3], - }, - }); - - // The speaking user's avatar should have the speaking class - const speakingAvatar = page.locator(".voice-user-item.speaking"); - await expect(speakingAvatar).toBeVisible({ timeout: 5000 }); - }); - - test("speaker indicator clears when user stops speaking", async ({ page }) => { - await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 }); - - // User starts speaking - await emitWsMessage(page, { - type: "voice_speakers", - payload: { channel_id: 10, speakers: [3] }, - }); - await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 }); - - // User stops speaking (empty speakers list) - await emitWsMessage(page, { - type: "voice_speakers", - payload: { channel_id: 10, speakers: [] }, - }); - await expect(page.locator(".voice-user-item.speaking")).toHaveCount(0, { timeout: 5000 }); - }); }); test.describe("Voice widget", () => { @@ -204,19 +168,7 @@ test.describe("Voice WS flow", () => { await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 }); }); - // 3. Speaker indicator animation — voice_speakers event adds .speaking class. - test("voice_speakers event adds speaking class to voice user", async ({ page }) => { - await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 }); - - await emitWsMessage(page, { - type: "voice_speakers", - payload: { channel_id: 10, speakers: [3] }, - }); - - await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 }); - }); - - // 4. Permission recovery button — grant mic button appears when + // 3. Permission recovery button — grant mic button appears when // listenOnly is true (display toggled via voice store subscription). test("grant mic button appears in listen-only mode", async ({ page }) => { await joinVoiceChannelByName(page); @@ -233,7 +185,7 @@ test.describe("Voice WS flow", () => { await expect(grantMicBtn).toBeVisible({ timeout: 5000 }); }); - // 5. Device hot-swap toast — simulate a toast notification for device change. + // 4. Device hot-swap toast — simulate a toast notification for device change. test("device change shows toast notification", async ({ page }) => { // Toast container is mounted by MainPage — inject a toast element. await page.evaluate(() => { @@ -251,7 +203,7 @@ test.describe("Voice WS flow", () => { await expect(toast).toBeVisible({ timeout: 5000 }); }); - // 6. Connection quality warning — stats pane auto-expands on quality degradation. + // 5. Connection quality warning — stats pane auto-expands on quality degradation. test("quality degradation auto-expands stats pane", async ({ page }) => { await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); @@ -269,7 +221,7 @@ test.describe("Voice WS flow", () => { await expect(statsPane).toHaveClass(/visible/, { timeout: 5000 }); }); - // 7. Mute/deafen toggle — buttons use aria-pressed and .active-ctrl class. + // 6. Mute/deafen toggle — buttons use aria-pressed and .active-ctrl class. test("mute and deafen buttons toggle state", async ({ page }) => { await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); @@ -287,7 +239,7 @@ test.describe("Voice WS flow", () => { await expect(deafenBtn).toHaveClass(/active-ctrl/); }); - // 8. Voice timer — joinedAt is set by joinVoiceChannel() on click. + // 7. Voice timer — joinedAt is set by joinVoiceChannel() on click. test("voice timer shows elapsed time", async ({ page }) => { await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); @@ -297,7 +249,7 @@ test.describe("Voice WS flow", () => { await expect(timer).toHaveText(/\d{2}:\d{2}/, { timeout: 5000 }); }); - // 9. Token refresh — emitting a new voice_token doesn't disconnect. + // 8. Token refresh — emitting a new voice_token doesn't disconnect. test("token refresh does not disconnect session", async ({ page }) => { await joinVoiceChannelByName(page); const widget = page.locator("[data-testid='voice-widget']"); diff --git a/Client/tests/integration/stores.test.ts b/Client/tests/integration/stores.test.ts index 078e11e4..3c0c5057 100644 --- a/Client/tests/integration/stores.test.ts +++ b/Client/tests/integration/stores.test.ts @@ -530,10 +530,10 @@ describe("Store integration via dispatcher", () => { }); // ──────────────────────────────────────────────────────────────── - // 8. Voice config and speakers + // 8. Voice config // ──────────────────────────────────────────────────────────────── - describe("voice config and speakers", () => { + describe("voice config", () => { it("stores voice config from voice_config event", () => { ws.simulate("voice_config", { channel_id: 3, @@ -554,65 +554,6 @@ describe("Store integration via dispatcher", () => { expect(config!.top_speakers).toBe(3); expect(config!.max_users).toBe(25); }); - - it("updates speaking states from voice_speakers event", () => { - // First seed voice users in channel 3 - ws.simulate("ready", { - channels: [], - members: [], - voice_states: [ - { channel_id: 3, user_id: 1, muted: false, deafened: false }, - { channel_id: 3, user_id: 2, muted: false, deafened: false }, - { channel_id: 3, user_id: 3, muted: false, deafened: false }, - ], - roles: [], - }); - - // User 1 and 3 are speaking - ws.simulate("voice_speakers", { - channel_id: 3, - speakers: [1, 3], - threshold_mode: "selective", - }); - - const channelUsers = voiceStore.getState().voiceUsers.get(3); - expect(channelUsers).toBeDefined(); - expect(channelUsers!.get(1)?.speaking).toBe(true); - expect(channelUsers!.get(2)?.speaking).toBe(false); - expect(channelUsers!.get(3)?.speaking).toBe(true); - }); - - it("clears speaking when user is no longer in speakers list", () => { - // Seed voice users - ws.simulate("ready", { - channels: [], - members: [], - voice_states: [ - { channel_id: 3, user_id: 1, muted: false, deafened: false }, - { channel_id: 3, user_id: 2, muted: false, deafened: false }, - ], - roles: [], - }); - - // User 1 speaking - ws.simulate("voice_speakers", { - channel_id: 3, - speakers: [1], - threshold_mode: "forwarding", - }); - - expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(true); - - // Now nobody speaking - ws.simulate("voice_speakers", { - channel_id: 3, - speakers: [], - threshold_mode: "forwarding", - }); - - expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(false); - expect(voiceStore.getState().voiceUsers.get(3)!.get(2)?.speaking).toBe(false); - }); }); // ──────────────────────────────────────────────────────────────── @@ -678,16 +619,6 @@ describe("Store integration via dispatcher", () => { expect(members.get(50)!.username).toBe("new-user"); }); - it("removes member on member_leave event", () => { - ws.simulate("member_join", { - user: { id: 51, username: "leaving-user", avatar: null, role: "member", status: "online" }, - }); - expect(membersStore.getState().members.has(51)).toBe(true); - - ws.simulate("member_leave", { user_id: 51 }); - expect(membersStore.getState().members.has(51)).toBe(false); - }); - it("updates member role on member_update event", () => { ws.simulate("member_join", { user: { id: 52, username: "role-user", avatar: null, role: "member", status: "online" }, diff --git a/Client/tests/unit/dispatcher.test.ts b/Client/tests/unit/dispatcher.test.ts index a6b99cdb..ffa03413 100644 --- a/Client/tests/unit/dispatcher.test.ts +++ b/Client/tests/unit/dispatcher.test.ts @@ -1101,23 +1101,6 @@ describe("WS Dispatcher", () => { expect(membersStore.getState().members.has(77)).toBe(false); }); - it("wires member_leave to members store", () => { - membersStore.setState((prev) => { - const m = new Map(prev.members); - m.set(99, { - id: 99, - username: "bye", - avatar: null, - role: "member", - status: "online" as const, - }); - return { ...prev, members: m }; - }); - - mock.dispatch("member_leave", { user_id: 99 }); - expect(membersStore.getState().members.has(99)).toBe(false); - }); - it("wires voice_state to voice store", () => { mock.dispatch("voice_state", { channel_id: 2, @@ -2977,38 +2960,6 @@ describe("WS Dispatcher", () => { expect(configs.get(3)).toBeDefined(); }); - it("wires voice_speakers to voice store", () => { - voiceStore.setState((prev) => { - const users = new Map( - [1, 2, 4].map((userId) => [ - userId, - { - userId, - username: `user${userId}`, - muted: false, - deafened: false, - speaking: false, - camera: false, - screenshare: false, - }, - ]), - ); - const voiceUsers = new Map(prev.voiceUsers); - voiceUsers.set(3, users); - return { ...prev, voiceUsers }; - }); - - mock.dispatch("voice_speakers", { - channel_id: 3, - speakers: [1, 2, 3], - }); - - const users = voiceStore.getState().voiceUsers.get(3); - expect(users?.get(1)?.speaking).toBe(true); - expect(users?.get(2)?.speaking).toBe(true); - expect(users?.get(4)?.speaking).toBe(false); - }); - it("wires voice_token to handleVoiceToken", async () => { const { handleVoiceToken } = await import("@lib/livekitSession"); diff --git a/Client/tests/unit/types.test.ts b/Client/tests/unit/types.test.ts index bb40cc31..06b82521 100644 --- a/Client/tests/unit/types.test.ts +++ b/Client/tests/unit/types.test.ts @@ -88,13 +88,12 @@ const sampleVoiceConfig = { }, }; -const sampleVoiceSpeakers = { - type: "voice_speakers" as const, - payload: { - channel_id: 10, - speakers: [1, 5, 12], - threshold_mode: "forwarding" as const, - }, +// Not a wire message — VoiceSpeakersPayload is the argument shape for +// voice.store's setSpeakers, fed by LiveKit's ActiveSpeakersChanged. +const sampleVoiceSpeakers: VoiceSpeakersPayload = { + channel_id: 10, + speakers: [1, 5, 12], + threshold_mode: "forwarding" as const, }; describe("ServerMessage discriminated union", () => { @@ -142,7 +141,7 @@ describe("AUDIT Critical: threshold_mode (CRIT-2, CRIT-3)", () => { }); it("VoiceSpeakersPayload uses threshold_mode NOT mode", () => { - const speakers: VoiceSpeakersPayload = sampleVoiceSpeakers.payload; + const speakers: VoiceSpeakersPayload = sampleVoiceSpeakers; expect(speakers.threshold_mode).toBe("forwarding"); // @ts-expect-error — mode is not a valid field expect(speakers.mode).toBeUndefined(); @@ -155,13 +154,6 @@ describe("AUDIT Critical: threshold_mode (CRIT-2, CRIT-3)", () => { expect(["forwarding", "selective"]).toContain(msg.payload.threshold_mode); } }); - - it("voice_speakers ServerMessage carries threshold_mode", () => { - const msg: ServerMessage = sampleVoiceSpeakers; - if (msg.type === "voice_speakers") { - expect(msg.payload.threshold_mode).toBeDefined(); - } - }); }); describe("AUDIT Critical: no channel_focus message type", () => { @@ -185,12 +177,10 @@ describe("AUDIT Critical: no channel_focus message type", () => { "voice_state", "voice_leave", "voice_config", - "voice_speakers", "voice_offer", "voice_answer", "voice_ice", "member_join", - "member_leave", "member_update", "member_ban", "server_restart", diff --git a/Client/tests/unit/voice.store.test.ts b/Client/tests/unit/voice.store.test.ts index 36c0ab18..d60ea2a7 100644 --- a/Client/tests/unit/voice.store.test.ts +++ b/Client/tests/unit/voice.store.test.ts @@ -664,12 +664,12 @@ describe("voice store", () => { expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.speaking).toBe(false); }); - it("updates remote users' speaking state from server", () => { - // Server says user 2 is speaking + it("updates remote users' speaking state from LiveKit", () => { + // LiveKit says user 2 is speaking setSpeakers({ channel_id: 10, speakers: [2], threshold_mode: "forwarding" }); expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(true); - // Server says nobody is speaking — remote user updated, local unchanged + // LiveKit says nobody is speaking — remote user updated, local unchanged setSpeakers({ channel_id: 10, speakers: [], threshold_mode: "forwarding" }); expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(false); }); diff --git a/Server/api/client_update_test.go b/Server/api/client_update_test.go index e3e3b441..415600e0 100644 --- a/Server/api/client_update_test.go +++ b/Server/api/client_update_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -273,3 +274,72 @@ func TestClientUpdate_GitHubError(t *testing.T) { t.Errorf("status = %d, want 502; body: %s", rr.Code, rr.Body.String()) } } + +// TestClientUpdate_Epoch1ResponseShape pins the exact JSON shape of the +// client-update 200 and 204 responses as of protocol epoch 1. B2-3 adds a +// protocol-epoch field to this response; when it does, this test WILL fail +// until it is extended on purpose to include the new field in the expected +// shape below. +// +// fakeGitHubRelease always publishes non-empty release notes and has no +// parameter for an empty body, so this covers the non-empty-notes case +// (top-level keys exactly {version, notes, platforms}) plus an explicit +// assertion that "pub_date" — omitempty, and never set by the handler — +// stays absent. +func TestClientUpdate_Epoch1ResponseShape(t *testing.T) { + srv := fakeGitHubRelease(t, "v2.0.0") + u := updater.NewUpdater("1.0.0", "", "test", "repo") + u.SetBaseURL(srv.URL) + + router := buildClientUpdateRouter(u) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "application/json; charset=utf-8") + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + want := map[string]any{ + "version": "2.0.0", + "notes": "Release notes here", + "platforms": map[string]any{ + "windows-x86_64-nsis": map[string]any{ + "signature": "dW50cnVzdGVkIGNvbW1lbnQ=", + "url": srv.URL + "/download/OwnCord_1.0.0_x64-setup.nsis.zip", + }, + }, + } + if !reflect.DeepEqual(resp, want) { + t.Errorf("200 response = %#v, want %#v — a new field (e.g. protocol epoch) must be added here deliberately", resp, want) + } + if _, ok := resp["pub_date"]; ok { + t.Errorf("response has a \"pub_date\" key = %v, want absent", resp["pub_date"]) + } + + // 204 (already latest) has an empty body, not "{}" or any other JSON. + srv204 := fakeGitHubRelease(t, "v1.0.0") + u204 := updater.NewUpdater("1.0.0", "", "test", "repo") + u204.SetBaseURL(srv204.URL) + router204 := buildClientUpdateRouter(u204) + + req204 := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil) + rr204 := httptest.NewRecorder() + router204.ServeHTTP(rr204, req204) + + if rr204.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", rr204.Code, rr204.Body.String()) + } + if rr204.Body.Len() != 0 { + t.Errorf("204 body length = %d, want 0 (body: %q)", rr204.Body.Len(), rr204.Body.String()) + } +} diff --git a/Server/updater/release_manifest_test.go b/Server/updater/release_manifest_test.go index 27fc5807..18300bcd 100644 --- a/Server/updater/release_manifest_test.go +++ b/Server/updater/release_manifest_test.go @@ -3,7 +3,9 @@ package updater import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" + "reflect" "testing" ) @@ -86,3 +88,75 @@ func TestVerifyReleaseManifest_LegacySingleAssetStillVerifies(t *testing.T) { t.Errorf("SHA256 = %s, want legacy hash", got.SHA256) } } + +// TestReleaseManifest_Epoch1Shape pins the exact JSON field set of +// releaseManifest as of protocol epoch 1: top level {version, asset, sha256, +// assets}, with each assets[i] exactly {asset, sha256}. B2-3 adds a +// protocol-epoch field to this manifest; when it does, this test WILL fail +// until it is extended on purpose to include the new field in the expected +// shapes below. +func TestReleaseManifest_Epoch1Shape(t *testing.T) { + full := releaseManifest{ + Version: "1.2.0", + Asset: "chatserver.exe", + SHA256: testHash("exe"), + Assets: []releaseManifestAsset{ + {Asset: "chatserver.exe", SHA256: testHash("exe")}, + {Asset: "chatserver-linux-amd64.tar.gz", SHA256: testHash("linux")}, + }, + } + data, err := json.Marshal(full) + if err != nil { + t.Fatalf("Marshal full manifest: %v", err) + } + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal full manifest: %v", err) + } + want := map[string]any{ + "version": "1.2.0", + "asset": "chatserver.exe", + "sha256": testHash("exe"), + "assets": []any{ + map[string]any{"asset": "chatserver.exe", "sha256": testHash("exe")}, + map[string]any{"asset": "chatserver-linux-amd64.tar.gz", "sha256": testHash("linux")}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("full manifest JSON = %#v, want %#v — a new/renamed field must be added here deliberately", got, want) + } + + // Assets == nil (legacy single-asset form) must omit "assets" entirely. + legacy := releaseManifest{Version: "1.2.0", Asset: "chatserver.exe", SHA256: testHash("exe")} + legacyData, err := json.Marshal(legacy) + if err != nil { + t.Fatalf("Marshal legacy manifest: %v", err) + } + var gotLegacy map[string]any + if err := json.Unmarshal(legacyData, &gotLegacy); err != nil { + t.Fatalf("Unmarshal legacy manifest: %v", err) + } + if _, ok := gotLegacy["assets"]; ok { + t.Errorf("legacy manifest JSON has an \"assets\" key = %v, want absent", gotLegacy["assets"]) + } + wantLegacy := map[string]any{ + "version": "1.2.0", + "asset": "chatserver.exe", + "sha256": testHash("exe"), + } + if !reflect.DeepEqual(gotLegacy, wantLegacy) { + t.Errorf("legacy manifest JSON = %#v, want %#v", gotLegacy, wantLegacy) + } + + // A rename in either struct must break this: unmarshal a hand-written + // legacy payload and assert the Go field values, not just presence. + const legacyJSON = `{"version":"1.2.0","asset":"chatserver.exe","sha256":"deadbeef"}` + var parsed releaseManifest + if err := json.Unmarshal([]byte(legacyJSON), &parsed); err != nil { + t.Fatalf("Unmarshal legacy JSON literal: %v", err) + } + wantParsed := releaseManifest{Version: "1.2.0", Asset: "chatserver.exe", SHA256: "deadbeef"} + if !reflect.DeepEqual(parsed, wantParsed) { + t.Errorf("parsed legacy manifest = %+v, want %+v", parsed, wantParsed) + } +} diff --git a/Server/ws/message_types.go b/Server/ws/message_types.go index 49efb6cd..cd83ee45 100644 --- a/Server/ws/message_types.go +++ b/Server/ws/message_types.go @@ -56,12 +56,10 @@ const ( MsgTypeVoiceState = "voice_state" MsgTypeVoiceConfig = "voice_config" MsgTypeVoiceToken = "voice_token" - MsgTypeVoiceSpeakers = "voice_speakers" MsgTypeVoiceLeaveBC = "voice_leave" // broadcast (same string as client msg) MsgTypeVoiceMoved = "voice_moved" MsgTypeVoiceDisconnected = "voice_disconnected" MsgTypeMemberJoin = "member_join" - MsgTypeMemberLeave = "member_leave" MsgTypeMemberUpdate = "member_update" MsgTypeUserUpdate = "user_update" MsgTypeMemberBan = "member_ban" diff --git a/Server/ws/protocol_epoch1_contract_test.go b/Server/ws/protocol_epoch1_contract_test.go new file mode 100644 index 00000000..d98737bd --- /dev/null +++ b/Server/ws/protocol_epoch1_contract_test.go @@ -0,0 +1,1159 @@ +package ws_test + +// protocol_epoch1_contract_test.go — golden wire transcripts for protocol +// epoch 1, the wire every client up to and including v1.2.0-alpha.4 speaks. +// +// protocol_contract_test.go locks the message-type *vocabulary* (schema.json +// against the generated Go constants). It says nothing about what a frame of a +// given type actually contains, or about which frames a journey produces in +// which order. B2-2 adds a protocol epoch to the auth handshake; once that +// merges there is no way to go back and record what epoch 1 looked like, so +// this file drives the required journeys through the package's in-process hub +// harness and compares each journey's frame sequence against a JSON transcript +// under protocol/fixtures/epoch-1/. +// +// What the fixtures lock, and what they deliberately do not: +// +// - Per connection, frames are compared IN ORDER. Cross-connection +// interleaving is timing-dependent and is not part of the contract, so +// each connection gets its own list and nothing relates the two. +// - Volatile values (ids, seqs, timestamps, tokens) are replaced by typed +// placeholders — "", "", "", +// "" — before both writing and comparison. The type stays +// visible so a field that changes from number to string still fails. +// channel_id and role_id are NOT normalised: they are deterministic in a +// freshly migrated database and are meaningful to the contract. The rule +// keys off the field NAME, not the meaning of the value, so a bare "id" +// (ready.channels[].id, a request's own id) and active_channel_id ARE +// normalised even where that value is in fact a channel id. That is by +// design, not an oversight: only the two exact names are exempt. It also +// costs one relationship: chat_send_ok echoes the id of the chat_send it +// answers, and both become "", so the fixture pins that the +// field is present and a string but not that the two match. +// Everything else — extra keys, missing keys, renames, enum values, null +// vs absent — is compared verbatim. That is the drift these exist to catch. +// - Optional fields are recorded in BOTH forms. alice carries a display +// name, an avatar, an about text, a custom status and an E2EE identity +// public key, and her voice_e2ee_announce carries a signature; bob carries +// none of them and announces without one. A field frozen only as +// null/absent would let a rename or a retype through unseen, and a field +// frozen only as present would leave the null form — the one a client must +// still handle — unfrozen. So fresh-connect records BOTH handshakes: on +// alice's connection auth_ok.user and member_join.user carry every profile +// field, on bob's they carry the nulls (auth_ok) and the omissions +// (member_join drops display_name and identity_public_key). auth_ok is the +// frame B2-2 changes, which is why it is the one recorded twice. +// - Only the journey's own frames are recorded. The connect handshake +// (auth_ok / ready / member_join / presence) and any channel_focus setup +// are drained without recording, EXCEPT in fresh-connect, resume-replay +// and auth-failure, where the handshake *is* the journey. Otherwise every +// fixture would carry its own copy of the ready payload and one ready +// change would rewrite eleven files. +// - A recorded connection closes its frame list with a ping/pong barrier +// (two exceptions, both named below), and where a frame must produce +// nothing at all (mark_read, the sender's own typing_start) that same pair +// doubles as the absence proof. +// pong is a direct reply on the normal-priority queue, so anything queued +// ahead of it there — or on the high-priority queue — arrives first and +// fails as `expected "pong", got "X"`. A LOW-priority frame (typing, +// presence_update) pending at the same instant is the one thing pong may +// overtake (writePump, serve_pumps.go:83-101); no journey is affected, because +// every barrier here is sent on a connection that is otherwise idle. +// Without the barrier a frame emitted after a journey's last read would +// never be recorded and the fixture would still pass — exactly the drift +// this file exists to catch. Two connections take it differently: +// auth-failure has no session left to ping, so the server's close after +// auth_error is the proof nothing follows; and resume-replay's "b" barriers +// where its recording window ends rather than where the journey does (the +// comment there says why moving it would be a flake, not a fix). +// - One position genuinely is not ordered by the server: a voice joiner's own +// voice_state arrives on the hub's asynchronous broadcast queue while the +// rest of its join burst is written directly by the handler goroutine. That +// frame is recorded on the peer's connection instead — see expectJoinBurst. +// +// Regenerate with: +// +// go test ./ws -run TestEpoch1Fixtures -update +// +// then read the diff. A fixture change is a protocol change; it needs the same +// scrutiny as editing protocol/schema.json. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/J3vb/OwnCord/Server/auth" + "github.com/J3vb/OwnCord/Server/config" + "github.com/J3vb/OwnCord/Server/db" + "github.com/J3vb/OwnCord/Server/service" + "github.com/J3vb/OwnCord/Server/ws" +) + +var updateFixtures = flag.Bool("update", false, "rewrite protocol/fixtures/epoch-1 from the live server") + +// frameDeadline bounds every individual read and write. A journey that hangs +// must fail with a named frame, not stall CI until the package timeout. +const frameDeadline = 5 * time.Second + +// authFailureCloseReason is the reason string the server pairs with close code +// 1008 when the handshake is rejected (serve.go:128). +const authFailureCloseReason = "authentication failed" + +// ─── fixture model ─────────────────────────────────────────────────────────── + +type wireFrame struct { + Dir string `json:"dir"` // "c2s" or "s2c" + Frame map[string]any `json:"frame"` +} + +type wireTranscript struct { + Journey string `json:"journey"` + Connections map[string][]wireFrame `json:"connections"` +} + +func newTranscript(journey string) *wireTranscript { + return &wireTranscript{Journey: journey, Connections: map[string][]wireFrame{}} +} + +func (tr *wireTranscript) add(conn, dir string, frame map[string]any) { + tr.Connections[conn] = append(tr.Connections[conn], wireFrame{Dir: dir, Frame: frame}) +} + +// ─── normalisation ─────────────────────────────────────────────────────────── + +// volatileClass classifies a JSON key whose value changes run to run. The +// returned class becomes the placeholder prefix; ok is false for every key +// whose value must be compared verbatim. +func volatileClass(key string) (string, bool) { + switch { + case key == "seq", key == "last_seq": + return "seq", true + case key == "channel_id", key == "role_id": + // Deterministic in a fresh database and load-bearing for the contract. + return "", false + case key == "id", strings.HasSuffix(key, "_id"): + return "id", true + case strings.HasSuffix(key, "_at"), key == "timestamp", key == "ts", key == "last_seen": + return "ts", true + case strings.Contains(key, "token"): + return "token", true + } + return "", false +} + +// jsonTypeOf names the JSON type of a decoded value so the placeholder keeps it +// visible: a field that flips from number to string is still a fixture diff. +func jsonTypeOf(v any) string { + switch v.(type) { + case nil: + return "null" + case bool: + return "bool" + case json.Number: + return "number" + case float64: + return "number" + case string: + return "string" + case []any: + return "array" + case map[string]any: + return "object" + } + return "unknown" +} + +// normaliseValue rewrites every volatile value in v (recursively, through both +// objects and arrays) to its typed placeholder. +func normaliseValue(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + if class, volatile := volatileClass(k); volatile { + out[k] = "<" + class + ":" + jsonTypeOf(val) + ">" + continue + } + out[k] = normaliseValue(val) + } + return out + case []any: + out := make([]any, len(t)) + for i := range t { + out[i] = normaliseValue(t[i]) + } + return out + default: + return v + } +} + +func normaliseTranscript(tr *wireTranscript) *wireTranscript { + out := newTranscript(tr.Journey) + for name, frames := range tr.Connections { + normalised := make([]wireFrame, len(frames)) + for i, f := range frames { + m, _ := normaliseValue(f.Frame).(map[string]any) + normalised[i] = wireFrame{Dir: f.Dir, Frame: m} + } + out.Connections[name] = normalised + } + return out +} + +// decodeFrame parses one wire frame. UseNumber keeps integers exact, so a +// regenerated fixture never turns 2147483647 into 2.147483647e+09. +func decodeFrame(t *testing.T, raw []byte) map[string]any { + t.Helper() + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.UseNumber() + var m map[string]any + if err := dec.Decode(&m); err != nil { + t.Fatalf("decoding frame %s: %v", raw, err) + } + return m +} + +// canonicalJSON renders v with sorted keys and stable two-space indentation, +// so key order never enters the comparison. HTML escaping is off: it is on by +// default in encoding/json (json.MarshalIndent included), and with it every +// placeholder would be written as "\u003cid:number\u003e" rather than +// "" — in a file whose whole job is to be read by a human +// reviewing a protocol change. +func canonicalJSON(t *testing.T, v any) string { + t.Helper() + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + t.Fatalf("marshalling fixture value: %v", err) + } + return strings.TrimRight(buf.String(), "\n") +} + +// ─── fixture I/O ───────────────────────────────────────────────────────────── + +// epoch1FixtureDir resolves protocol/fixtures/epoch-1 relative to THIS file +// (ws/ -> Server/ -> repo root -> protocol/), never from the working directory +// `go test` happened to be invoked from. Same rule as loadProtocolSchema. +func epoch1FixtureDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed to resolve test file path") + } + return filepath.Join(filepath.Dir(thisFile), "..", "..", "protocol", "fixtures", "epoch-1") +} + +// verify writes the transcript under -update, and otherwise compares it with +// the committed fixture frame by frame. +func (tr *wireTranscript) verify(t *testing.T) { + t.Helper() + dir := epoch1FixtureDir(t) + path := filepath.Join(dir, tr.Journey+".json") + got := normaliseTranscript(tr) + + if *updateFixtures { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("journey %q: creating %s: %v", tr.Journey, dir, err) + } + body := append([]byte(canonicalJSON(t, got)), '\n') + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("journey %q: writing %s: %v", tr.Journey, path, err) + } + t.Logf("journey %q: wrote %s", tr.Journey, path) + return + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("journey %q: reading %s: %v\nregenerate with: go test ./ws -run TestEpoch1Fixtures -update", + tr.Journey, path, err) + } + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.UseNumber() + var want wireTranscript + if err := dec.Decode(&want); err != nil { + t.Fatalf("journey %q: parsing %s: %v", tr.Journey, path, err) + } + compareTranscripts(t, &want, got) +} + +func compareTranscripts(t *testing.T, want, got *wireTranscript) { + t.Helper() + if want.Journey != got.Journey { + t.Errorf("journey %q: fixture records journey %q", got.Journey, want.Journey) + } + names := map[string]struct{}{} + for n := range want.Connections { + names[n] = struct{}{} + } + for n := range got.Connections { + names[n] = struct{}{} + } + sorted := make([]string, 0, len(names)) + for n := range names { + sorted = append(sorted, n) + } + sort.Strings(sorted) + + for _, name := range sorted { + w, g := want.Connections[name], got.Connections[name] + for i := 0; i < len(w) || i < len(g); i++ { + switch { + case i >= len(g): + t.Errorf("journey %q connection %q: frame %d missing (fixture has %d frames, server sent %d)\nfixture:\n%s", + got.Journey, name, i, len(w), len(g), canonicalJSON(t, w[i])) + case i >= len(w): + t.Errorf("journey %q connection %q: frame %d unexpected (fixture has %d frames, server sent %d)\nserver:\n%s", + got.Journey, name, i, len(w), len(g), canonicalJSON(t, g[i])) + default: + wc, gc := canonicalJSON(t, w[i]), canonicalJSON(t, g[i]) + if wc != gc { + t.Errorf("journey %q connection %q: frame %d differs\nfixture:\n%s\nserver:\n%s\nregenerate with: go test ./ws -run TestEpoch1Fixtures -update", + got.Journey, name, i, wc, gc) + } + } + } + } +} + +// ─── in-process hub harness ────────────────────────────────────────────────── + +// epochRig is the package's usual end-to-end wiring (full migrations, real +// hub, httptest WebSocket server) collected into one place so eleven journeys +// do not repeat it. It is not a new abstraction over the harness — it is the +// same calls reconnect_db_test.go and coverage_helpers_test.go make. +type epochRig struct { + db *db.DB + wsURL string + tr *wireTranscript +} + +func newEpochRig(t *testing.T, journey string) *epochRig { + t.Helper() + + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter, service.New(database, limiter)) + + // A LiveKit client so voice_join clears the "voice not configured" guard. + // The join token is minted locally; no LiveKit process is contacted. + lk, err := ws.NewLiveKitClient(&config.VoiceConfig{ + LiveKitAPIKey: "test-api-key-12345", + LiveKitAPISecret: "test-api-secret-67890abcdef", + LiveKitURL: "ws://localhost:7880", + }) + if err != nil { + t.Fatalf("NewLiveKitClient: %v", err) + } + hub.SetLiveKit(lk) + + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + + srv := httptest.NewServer(ws.ServeWS(hub, database, []string{"*"}, 0)) + t.Cleanup(srv.Close) + + return &epochRig{ + db: database, + wsURL: "ws" + strings.TrimPrefix(srv.URL, "http"), + tr: newTranscript(journey), + } +} + +// seedUser creates a Member-role user (role 4 carries READ/SEND/REACT plus +// CONNECT_VOICE and SPEAK — see migrations 005 and 007) and an active session, +// returning the user id and the raw session token. +func (r *epochRig) seedUser(t *testing.T, username string) (int64, string) { + t.Helper() + ctx := context.Background() + userID, err := r.db.CreateUser(ctx, username, "hash", 4) + if err != nil { + t.Fatalf("CreateUser(%s): %v", username, err) + } + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := r.db.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession(%s): %v", username, err) + } + return userID, token +} + +// fillAliceProfile sets every optional profile field on alice, so the fixtures +// record their present form and not just their null/absent one. bob is left +// bare on purpose — that is how both forms end up frozen. The values are +// obviously test data; the avatar is an https URL because that is the only +// shape the REST profile path accepts (api/profile_handler.go:120). +func (r *epochRig) fillAliceProfile(t *testing.T, userID int64) { + t.Helper() + ctx := context.Background() + avatar := "https://fixtures.invalid/alice-avatar.png" + displayName := "Alice Fixture" + about := "fixture profile text" + customStatus := "fixture custom status" + // A long-term E2EE identity key, base64 of an obvious test string. It is + // NOT normalised — the rule replaces keys containing "token", and + // identity_public_key is not one — so the fixture pins the value verbatim. + identityKey := "YWxpY2UtaWRlbnRpdHktcHVibGljLWtleS1maXh0dXJl" + if err := r.db.UpdateUserProfile(ctx, userID, "alice", &avatar, &displayName, &about); err != nil { + t.Fatalf("UpdateUserProfile(alice): %v", err) + } + if err := r.db.UpdateUserCustomStatus(ctx, userID, &customStatus); err != nil { + t.Fatalf("UpdateUserCustomStatus(alice): %v", err) + } + if err := r.db.UpdateUserIdentityKey(ctx, userID, &identityKey); err != nil { + t.Fatalf("UpdateUserIdentityKey(alice): %v", err) + } +} + +// seedBaseline gives every journey the same starting database: two members, +// one text channel (id 1) and one voice channel (id 2). Channel ids are not +// normalised, so keeping the seed identical keeps them readable across +// fixtures. +// +// alice fills in every optional profile field and bob fills in none, so each +// omitempty/nullable field is frozen in BOTH forms: present (where a rename or +// a retype is visible) and absent. Freezing only the absent form would let +// display_name or identity_public_key be renamed, retyped or dropped without a +// single fixture moving. +func (r *epochRig) seedBaseline(t *testing.T) (aliceID, bobID int64, aliceTok, bobTok string) { + t.Helper() + aliceID, aliceTok = r.seedUser(t, "alice") + bobID, bobTok = r.seedUser(t, "bob") + r.fillAliceProfile(t, aliceID) + ctx := context.Background() + textID, err := r.db.CreateChannel(ctx, "general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel(general): %v", err) + } + voiceID, err := r.db.CreateChannel(ctx, "Voice", "voice", "", "", 1) + if err != nil { + t.Fatalf("CreateChannel(Voice): %v", err) + } + // channel_id is the one id the fixtures record verbatim, so pin the seed's + // assignment rather than letting a fixture quietly re-anchor to new numbers. + if textID != textChannelID || voiceID != voiceChannelID { + t.Fatalf("seed channel ids = (%d, %d), want (%d, %d) — the fixtures record channel_id verbatim", + textID, voiceID, textChannelID, voiceChannelID) + } + return aliceID, bobID, aliceTok, bobTok +} + +const ( + textChannelID = 1 + voiceChannelID = 2 +) + +// ─── connection helpers ────────────────────────────────────────────────────── + +type wsConn struct { + t *testing.T + name string + tr *wireTranscript + conn *websocket.Conn + record bool + lastSeq uint64 +} + +// dial opens a WebSocket to the rig and registers its close in t.Cleanup — +// the ws package runs goleak.VerifyTestMain, so nothing may outlive the test. +func (r *epochRig) dial(t *testing.T, name string) *wsConn { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), frameDeadline) + defer cancel() + conn, resp, err := websocket.Dial(ctx, r.wsURL, nil) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + t.Fatalf("conn %q: websocket.Dial: %v", name, err) + } + // Headroom, not a requirement: the largest frame these journeys produce + // (ready) is ~1.5 KiB, well inside coder/websocket's 32 KiB default. A + // fixture run must fail on a frame that changed, not on a read limit, if a + // future seed or a fatter ready pushes past it. + conn.SetReadLimit(1 << 20) + t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "") }) + return &wsConn{t: t, name: name, tr: r.tr, conn: conn} +} + +func (c *wsConn) send(frame map[string]any) { + c.t.Helper() + raw, err := json.Marshal(frame) + if err != nil { + c.t.Fatalf("conn %q: marshalling %v: %v", c.name, frame, err) + } + ctx, cancel := context.WithTimeout(context.Background(), frameDeadline) + defer cancel() + if err := c.conn.Write(ctx, websocket.MessageText, raw); err != nil { + c.t.Fatalf("conn %q: writing %s: %v", c.name, raw, err) + } + if c.record { + c.tr.add(c.name, "c2s", decodeFrame(c.t, raw)) + } +} + +// readRaw takes exactly one frame, bounded by frameDeadline, and tracks the +// highest seq seen so a resume can replay from it the way a real client does. +// It never records; use read for that. +func (c *wsConn) readRaw() map[string]any { + c.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), frameDeadline) + defer cancel() + _, raw, err := c.conn.Read(ctx) + if err != nil { + c.t.Fatalf("conn %q: read: %v", c.name, err) + } + frame := decodeFrame(c.t, raw) + if n, ok := frame["seq"].(json.Number); ok { + if v, err := n.Int64(); err == nil && v > 0 && uint64(v) > c.lastSeq { + c.lastSeq = uint64(v) + } + } + return frame +} + +func (c *wsConn) read() map[string]any { + c.t.Helper() + frame := c.readRaw() + if c.record { + c.tr.add(c.name, "s2c", frame) + } + return frame +} + +// expect reads one frame and asserts its envelope type. +func (c *wsConn) expect(msgType string) map[string]any { + c.t.Helper() + frame := c.read() + if got := frame["type"]; got != msgType { + c.t.Fatalf("conn %q: expected %q, got %q: %s", c.name, msgType, got, canonicalJSON(c.t, frame)) + } + return frame +} + +// barrier closes a connection's recorded frame list: it sends ping and asserts +// the next frame is pong. pong is a direct reply, so any frame the server +// queued before it arrives first and fails as `expected "pong", got "X"` — +// which is what makes a trailing frame a fixture failure instead of a frame +// nobody ever reads. The pair is recorded; it is part of the transcript. +// +// Ping budget: handlePingV2 rate-limits ping to 2 per second per USER +// (handlers_ping.go:14) and drops the excess SILENTLY, which would turn a +// third ping into a 5 s read-deadline failure rather than an error frame. No +// journey may spend more than two per user per second: focus(_, true) spends +// one and this spends one, which is the ceiling six connections already sit +// on — bob in chat-send-fanout, chat-edit-delete, reaction-add-remove, typing +// and resume-replay (a focus barrier plus a closing one), and alice in ping +// (the journey's own pair). Need another? Give that user a second connection, +// do not add a sleep. +func (c *wsConn) barrier() { + c.t.Helper() + c.send(map[string]any{"type": "ping", "payload": map[string]any{}}) + c.expect("pong") +} + +// expectClosed asserts the server hung up rather than sending another frame, +// with the close code and reason the handshake failure path uses. It is +// auth-failure's barrier: there is no session left to ping, so the close +// itself (serve.go:128 — 1008 policy violation, "authentication failed") is +// the proof that auth_error is the last thing on that socket. Both halves are +// part of the epoch-1 contract, so both are asserted here rather than only the +// code. +func (c *wsConn) expectClosed() { + c.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), frameDeadline) + defer cancel() + _, raw, err := c.conn.Read(ctx) + if err == nil { + c.t.Fatalf("conn %q: expected the server to close, got frame %s", c.name, raw) + } + var closeErr websocket.CloseError + if !errors.As(err, &closeErr) { + c.t.Fatalf("conn %q: expected a WebSocket close, got %v", c.name, err) + } + if closeErr.Code != websocket.StatusPolicyViolation || closeErr.Reason != authFailureCloseReason { + c.t.Fatalf("conn %q: close = %v %q, want %v %q", c.name, + closeErr.Code, closeErr.Reason, websocket.StatusPolicyViolation, authFailureCloseReason) + } +} + +// drain reads the named frames without recording them — journey setup, not +// journey content. Naming them (rather than counting) keeps the setup honest: +// an unexpected frame fails here instead of shifting the transcript. +func (c *wsConn) drain(types ...string) { + c.t.Helper() + was := c.record + c.record = false + for _, ty := range types { + c.expect(ty) + } + c.record = was +} + +// authenticate sends the auth frame and drains the fresh-connect handshake +// (auth_ok, ready, and the member_join + presence this connect broadcasts to +// every client, itself included). +func (c *wsConn) authenticate(token string) { + c.t.Helper() + was := c.record + c.record = false + c.send(map[string]any{ + "type": "auth", + "id": "req-auth-" + c.name, + "payload": map[string]any{"token": token, "last_seq": 0}, + }) + c.drain("auth_ok", "ready", "member_join", "presence") + c.record = was +} + +// expectJoinBurst reads a voice_join's reply burst — one more frame than +// `want` names — and records only the frames the server orders. +// +// A joiner's OWN voice_state reaches it through the hub's asynchronous +// broadcast queue (broadcastVoiceEvent -> h.broadcast -> deliverBroadcast on +// the hub goroutine) while voice_token, the existing participants' relayed +// voice_state frames and voice_config are written straight to its send queue +// by the handler goroutine. Nothing orders the two against each other: under +// -tags deadlock a joiner's own voice_state was observed arriving after +// voice_config roughly once in thirty runs. Its position on THIS socket is +// therefore not part of the contract and is not recorded — the same frame is +// recorded on the peer's connection, where it is the only thing in flight and +// its position is well defined. The direct sends keep their relative order +// (one goroutine, program order), and `want` asserts it. +func (c *wsConn) expectJoinBurst(selfUserID int64, want ...string) { + c.t.Helper() + var got []string + skipped := false + for i := 0; i < len(want)+1; i++ { + frame := c.readRaw() + ty, _ := frame["type"].(string) + if !skipped && ty == "voice_state" && framePayloadUserID(c.t, frame) == selfUserID { + skipped = true + continue + } + if c.record { + c.tr.add(c.name, "s2c", frame) + } + got = append(got, ty) + } + if !skipped { + c.t.Fatalf("conn %q: join burst %v carried no voice_state for user %d", c.name, got, selfUserID) + } + if len(got) != len(want) { + c.t.Fatalf("conn %q: join burst %v, want %v", c.name, got, want) + } + for i := range want { + if got[i] != want[i] { + c.t.Fatalf("conn %q: join burst %v, want %v", c.name, got, want) + } + } +} + +// framePayloadUserID reads payload.user_id, or 0 when absent. +func framePayloadUserID(t *testing.T, frame map[string]any) int64 { + t.Helper() + payload, _ := frame["payload"].(map[string]any) + n, ok := payload["user_id"].(json.Number) + if !ok { + return 0 + } + v, err := n.Int64() + if err != nil { + return 0 + } + return v +} + +// focus subscribes the connection to a channel's topic and, for an observer +// whose subscription must be live before another connection acts, waits for a +// ping/pong round trip. Frames on one connection are handled in order, so the +// actor needs no barrier for its own focus. +func (c *wsConn) focus(channelID int64, barrier bool) { + c.t.Helper() + was := c.record + c.record = false + c.send(map[string]any{ + "type": "channel_focus", + "payload": map[string]any{"channel_id": channelID}, + }) + if barrier { + c.barrier() + } + c.record = was +} + +// ─── journeys ──────────────────────────────────────────────────────────────── + +type epochJourney struct { + name string + run func(t *testing.T, r *epochRig) +} + +func TestEpoch1Fixtures(t *testing.T) { + journeys := []epochJourney{ + {"fresh-connect", journeyFreshConnect}, + {"auth-failure", journeyAuthFailure}, + {"ping", journeyPing}, + {"chat-send-fanout", journeyChatSendFanout}, + {"chat-edit-delete", journeyChatEditDelete}, + {"reaction-add-remove", journeyReactionAddRemove}, + {"typing", journeyTyping}, + {"mark-read", journeyMarkRead}, + {"dm-send", journeyDMSend}, + {"resume-replay", journeyResumeReplay}, + {"voice-join-e2ee-leave", journeyVoiceJoinE2EELeave}, + } + for _, j := range journeys { + t.Run(j.name, func(t *testing.T) { + rig := newEpochRig(t, j.name) + j.run(t, rig) + rig.tr.verify(t) + }) + } +} + +// journeyFreshConnect records the whole handshake — auth, auth_ok, ready, and +// the member_join + presence a connect broadcasts (which the connecting client +// receives too) — TWICE, once per kind of account. +// +// alice has every optional profile field set; bob has none. Recording only +// alice would freeze auth_ok.user and member_join.user in their populated form +// alone, so a rename, a retype or a dropped null on display_name, about, +// custom_status, avatar or identity_public_key would move no fixture. auth_ok +// is the frame B2-2 edits, which makes it the last one to leave half-frozen. +// +// bob's connect is also observed on alice's socket, which is idle by then: the +// two broadcasts reach her through the hub's per-client FIFO in the order they +// were sequenced, so her reads stay deterministic. One ping each, well inside +// the per-user budget (see barrier). +func journeyFreshConnect(t *testing.T, r *epochRig) { + _, _, aliceTok, bobTok := r.seedBaseline(t) + + a := r.dial(t, "a") + a.record = true + // The real client stamps a correlation id on every frame it sends, + // auth included (ws.ts send()); the server ignores it here. + a.send(map[string]any{ + "type": "auth", + "id": "req-auth-alice", + "payload": map[string]any{"token": aliceTok, "last_seq": 0}, + }) + a.expect("auth_ok") + a.expect("ready") + a.expect("member_join") + a.expect("presence") + + // bob, recorded: the same five frames with the bare user object — nulls + // for avatar/display_name/about/custom_status, identity_public_key omitted. + b := r.dial(t, "b") + b.record = true + b.send(map[string]any{ + "type": "auth", + "id": "req-auth-bob", + "payload": map[string]any{"token": bobTok, "last_seq": 0}, + }) + b.expect("auth_ok") + b.expect("ready") + b.expect("member_join") + b.expect("presence") + + // bob's connect as an already-connected client sees it. + a.expect("member_join") + a.expect("presence") + + a.barrier() + b.barrier() +} + +// journeyAuthFailure records the rejection an unknown session token earns. +// The two sibling rejections share the frame shape and differ only in the +// message string: "invalid message" (unparseable first frame) and "first +// message must be auth" (a first frame of any other type). +// +// This is the one journey with no ping/pong barrier: there is no session to +// ping. The server closes the socket right after the frame (serve.go:126-129), +// so the close is the proof that auth_error is the last thing said — and an +// extra frame slipped in ahead of it fails expectClosed rather than passing +// unrecorded. +func journeyAuthFailure(t *testing.T, r *epochRig) { + r.seedBaseline(t) + + a := r.dial(t, "a") + a.record = true + a.send(map[string]any{ + "type": "auth", + "id": "req-auth-rejected", + "payload": map[string]any{"token": "not-a-real-session-token", "last_seq": 0}, + }) + a.expect("auth_error") + a.expectClosed() +} + +// journeyPing records the heartbeat round trip twice: the second pair is the +// barrier proving nothing follows the first pong. Two is the whole per-second +// ping budget (see barrier), so this journey sits exactly on the ceiling. +func journeyPing(t *testing.T, r *epochRig) { + _, _, aliceTok, _ := r.seedBaseline(t) + + a := r.dial(t, "a") + a.authenticate(aliceTok) + + a.record = true + a.send(map[string]any{"type": "ping", "payload": map[string]any{}}) + a.expect("pong") + a.barrier() +} + +// journeyChatSendFanout records a channel message: the sender's direct +// chat_send_ok reply plus the sequenced chat_message every focused client in +// the channel receives, sender included. +func journeyChatSendFanout(t *testing.T, r *epochRig) { + _, _, aliceTok, bobTok := r.seedBaseline(t) + + a, b := r.dial(t, "a"), r.dial(t, "b") + a.authenticate(aliceTok) + b.authenticate(bobTok) + a.drain("member_join", "presence") // b's connect, observed by a + a.focus(textChannelID, false) + b.focus(textChannelID, true) + + a.record, b.record = true, true + a.send(map[string]any{ + "type": "chat_send", + "id": "req-chat-send-1", + "payload": map[string]any{"channel_id": textChannelID, "content": "hello epoch one"}, + }) + a.expect("chat_send_ok") + a.expect("chat_message") + b.expect("chat_message") + a.barrier() + b.barrier() +} + +// journeyChatEditDelete records the two mutations of an existing message. +// Neither carries a direct reply — the broadcast is the whole answer, so the +// author learns the outcome the same way every other client does. +func journeyChatEditDelete(t *testing.T, r *epochRig) { + aliceID, _, aliceTok, bobTok := r.seedBaseline(t) + msgID, err := r.db.CreateMessage(context.Background(), textChannelID, aliceID, "original text", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + a, b := r.dial(t, "a"), r.dial(t, "b") + a.authenticate(aliceTok) + b.authenticate(bobTok) + a.drain("member_join", "presence") + a.focus(textChannelID, false) + b.focus(textChannelID, true) + + a.record, b.record = true, true + a.send(map[string]any{ + "type": "chat_edit", + "id": "req-chat-edit-1", + "payload": map[string]any{"message_id": msgID, "content": "edited text"}, + }) + a.expect("chat_edited") + b.expect("chat_edited") + + a.send(map[string]any{ + "type": "chat_delete", + "id": "req-chat-delete-1", + "payload": map[string]any{"message_id": msgID}, + }) + a.expect("chat_deleted") + b.expect("chat_deleted") + a.barrier() + b.barrier() +} + +// journeyReactionAddRemove records both halves of a reaction. Add and remove +// share one frame type and differ only in the action field. +func journeyReactionAddRemove(t *testing.T, r *epochRig) { + aliceID, _, aliceTok, bobTok := r.seedBaseline(t) + msgID, err := r.db.CreateMessage(context.Background(), textChannelID, aliceID, "react to me", nil) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + a, b := r.dial(t, "a"), r.dial(t, "b") + a.authenticate(aliceTok) + b.authenticate(bobTok) + a.drain("member_join", "presence") + a.focus(textChannelID, false) + b.focus(textChannelID, true) + + a.record, b.record = true, true + a.send(map[string]any{ + "type": "reaction_add", + "id": "req-reaction-add-1", + "payload": map[string]any{"message_id": msgID, "emoji": "👍"}, + }) + a.expect("reaction_update") + b.expect("reaction_update") + + a.send(map[string]any{ + "type": "reaction_remove", + "id": "req-reaction-remove-1", + "payload": map[string]any{"message_id": msgID, "emoji": "👍"}, + }) + a.expect("reaction_update") + b.expect("reaction_update") + a.barrier() + b.barrier() +} + +// journeyTyping records the typing indicator, which is delivered on the +// low-priority queue and excludes its sender. The ping/pong pair on "a" is the +// absence proof: the typist must not see its own typing frame. +// +// "a" focuses the channel first, exactly like the other fan-out journeys. That +// is what makes the absence proof mean anything: a live client joins a +// channel's topic only when it focuses that channel (handleChannelFocusV2, +// handlers_presence.go:97, whose SetChannelID result the hub applies), so an +// unfocused "a" could not receive the frame no matter what excludeUserID did, +// and the barrier would be proving the subscription was missing rather than +// that the server excludes the sender. +func journeyTyping(t *testing.T, r *epochRig) { + _, _, aliceTok, bobTok := r.seedBaseline(t) + + a, b := r.dial(t, "a"), r.dial(t, "b") + a.authenticate(aliceTok) + b.authenticate(bobTok) + a.drain("member_join", "presence") + a.focus(textChannelID, false) + b.focus(textChannelID, true) + + a.record, b.record = true, true + a.send(map[string]any{ + "type": "typing_start", + "payload": map[string]any{"channel_id": textChannelID}, + }) + b.expect("typing") + a.barrier() + b.barrier() +} + +// journeyMarkRead records the one client→server frame that answers with +// nothing at all. The ping/pong pair proves the silence. +func journeyMarkRead(t *testing.T, r *epochRig) { + aliceID, _, aliceTok, _ := r.seedBaseline(t) + if _, err := r.db.CreateMessage(context.Background(), textChannelID, aliceID, "unread", nil); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + a := r.dial(t, "a") + a.authenticate(aliceTok) + + a.record = true + a.send(map[string]any{ + "type": "mark_read", + "payload": map[string]any{"channel_id": textChannelID}, + }) + a.barrier() +} + +// journeyDMSend records a direct message. DM traffic is addressed to the +// participant ids rather than a channel topic, so neither side needs focus, +// and the whole fan-out is synchronous on the sender's connection. +func journeyDMSend(t *testing.T, r *epochRig) { + aliceID, bobID, aliceTok, bobTok := r.seedBaseline(t) + dmChannel, _, err := r.db.GetOrCreateDMChannel(context.Background(), aliceID, bobID) + if err != nil { + t.Fatalf("GetOrCreateDMChannel: %v", err) + } + + a, b := r.dial(t, "a"), r.dial(t, "b") + a.authenticate(aliceTok) + b.authenticate(bobTok) + a.drain("member_join", "presence") + + a.record, b.record = true, true + a.send(map[string]any{ + "type": "chat_send", + "id": "req-dm-send-1", + "payload": map[string]any{"channel_id": dmChannel.ID, "content": "hello over dm"}, + }) + a.expect("chat_send_ok") + a.expect("chat_message") + b.expect("chat_message") + a.barrier() + b.barrier() +} + +// journeyResumeReplay records the reconnect handshake: the actor drops, misses +// two sequenced events, and resumes with last_seq — earning auth_ok with +// replay_source "buffer" followed by the replay burst, then its own live +// come-back-online presence. +func journeyResumeReplay(t *testing.T, r *epochRig) { + _, _, aliceTok, bobTok := r.seedBaseline(t) + + a, b := r.dial(t, "a"), r.dial(t, "b") + a.authenticate(aliceTok) + b.authenticate(bobTok) + a.drain("member_join", "presence") + b.focus(textChannelID, true) + + lastSeq := a.lastSeq + if lastSeq == 0 { + t.Fatal("conn \"a\" saw no sequenced frame during the handshake; a resume needs one") + } + + // Drop the actor. Reading b's offline presence is the barrier: it proves + // the disconnect broadcast has been sequenced before b posts, so the two + // replayed events always land in the same order. + b.record = true + if err := a.conn.Close(websocket.StatusNormalClosure, "resume"); err != nil { + t.Fatalf("closing conn \"a\": %v", err) + } + b.expect("presence") + + b.send(map[string]any{ + "type": "chat_send", + "id": "req-resume-1", + "payload": map[string]any{"channel_id": textChannelID, "content": "sent while away"}, + }) + b.expect("chat_send_ok") + b.expect("chat_message") + // b's barrier closes ITS recorded list here rather than at the end of the + // journey — the second of the file header's two barrier exceptions. + // Everything b has to say about the away window is said, and the only frame + // the server can still send it is alice's back-online presence from the + // resume below, whose shape is already recorded on a2 as the last frame of + // the replay burst. Moving the barrier past the resume is NOT the fix it + // looks like: b's ping would then race the tail of the hub's fan-out loop + // for that presence, and pong could win — a flake that -race and -tags + // deadlock surface on a loaded runner, in a file whose whole job is to fail + // only for real drift. + b.barrier() + b.record = false + + a2 := r.dial(t, "a") + a2.record = true + a2.send(map[string]any{ + "type": "auth", + "id": "req-auth-resume", + "payload": map[string]any{ + "token": aliceTok, + "last_seq": lastSeq, + "active_channel_id": textChannelID, + }, + }) + authOK := a2.expect("auth_ok") + payload, _ := authOK["payload"].(map[string]any) + if src := payload["replay_source"]; src != "buffer" { + t.Fatalf("resume served from %q, want \"buffer\" — the replay tier under test", src) + } + a2.expect("presence") // replayed: the actor's own disconnect + a2.expect("chat_message") // replayed: what it missed + a2.expect("presence") // live: back online + a2.barrier() +} + +// journeyVoiceJoinE2EELeave records a two-party encrypted call: both join, the +// key holder (lowest connected user id in the room) announces its ECDH public +// key and offers the room key to its peer, then leaves. +// +// The fixture carries both forms of voice_state: the sequenced broadcast a +// room's audience receives, and the unsequenced copy the joiner is handed for +// each participant already in the room. See expectJoinBurst for why a joiner's +// own voice_state is recorded on its peer's connection rather than its own. +func journeyVoiceJoinE2EELeave(t *testing.T, r *epochRig) { + aliceID, bobID, aliceTok, bobTok := r.seedBaseline(t) + + a, b := r.dial(t, "a"), r.dial(t, "b") + a.authenticate(aliceTok) + b.authenticate(bobTok) + a.drain("member_join", "presence") + + a.record, b.record = true, true + + // alice joins an empty room: token, then room config. + a.send(map[string]any{ + "type": "voice_join", + "payload": map[string]any{"channel_id": voiceChannelID}, + }) + a.expectJoinBurst(aliceID, "voice_token", "voice_config") + b.expect("voice_state") // alice's, broadcast to the room's audience + + // bob joins an occupied room: token, the state of every participant already + // there, then room config. Reading his voice_config is also the barrier for + // the announce below — the voice-topic subscription he needs to receive it + // is made earlier in the same handler. + b.send(map[string]any{ + "type": "voice_join", + "payload": map[string]any{"channel_id": voiceChannelID}, + }) + b.expectJoinBurst(bobID, "voice_token", "voice_state", "voice_config") + a.expect("voice_state") // bob's, broadcast to the room's audience + + // alice publishes her ECDH public key, signed by her identity key; the + // server checks only the size and base64-ness of both and relays them + // verbatim (voice_e2ee.go:126-140) — it never verifies the signature. + a.send(map[string]any{ + "type": "voice_e2ee_announce", + "payload": map[string]any{ + "public_key": "YWxpY2UtZWNkaC1wdWJsaWMta2V5LWZpeHR1cmU=", + "signature": "YWxpY2Utc2lnbmF0dXJlLW92ZXItaGVyLWVjZGgta2V5", + }, + }) + b.expect("voice_e2ee_announce") + + // bob answers with a legacy announce and no signature at all. signature is + // omitempty on both the command and the relay, so this is what freezes its + // ABSENT form — alice's above froze the present one, and a field recorded + // only one way would let the other be renamed or dropped unseen. The relay + // excludes its sender and alice is idle, so it is the only frame in flight + // on her socket. + b.send(map[string]any{ + "type": "voice_e2ee_announce", + "payload": map[string]any{ + "public_key": "Ym9iLWVjZGgtcHVibGljLWtleS1maXh0dXJl", + }, + }) + a.expect("voice_e2ee_announce") + + // alice is the key holder (lowest user id in the room) and offers the room + // key to bob. + a.send(map[string]any{ + "type": "voice_e2ee_offer", + "payload": map[string]any{ + "target_user_id": bobID, + "encrypted_key": "ZW5jcnlwdGVkLXJvb20ta2V5LWZpeHR1cmU=", + "iv": "aXYtZml4dHVyZS0xMg==", + }, + }) + b.expect("voice_e2ee_offer") + + a.send(map[string]any{"type": "voice_leave", "payload": map[string]any{}}) + a.expect("voice_leave") + b.expect("voice_leave") + a.barrier() + b.barrier() +} diff --git a/docs/architecture/ux/README.md b/docs/architecture/ux/README.md index 38e0b667..ed27bb54 100644 --- a/docs/architecture/ux/README.md +++ b/docs/architecture/ux/README.md @@ -119,28 +119,28 @@ the stores. Target: **every** inbound message type produces a defined store mutation _and_, where user-visible, a defined UI reaction. The per-flow docs detail each; this is the index. -| Inbound event | Store effect | Target UI reaction | -| ----------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `auth_ok` | `auth.setAuth` | Advance handshake → ready overlay | -| `auth_error` | `ui.setTransientError` + `auth.clearAuth` | Return to connect page with the reason shown | -| `ready` | bulk-load channels/roles/members/voice/dm | Render main view; resolve the connected overlay | -| `chat_message` | `messages.addMessage` (+ unread/DM/notify) | Append; reconcile a pending optimistic row if it's our echo | -| `chat_send_ok` | `messages.confirmSend` | Mark the optimistic row **sent** (see gap in [messaging.md](messaging.md)) | -| `chat_edited` / `chat_deleted` | `messages.editMessage` / `deleteMessage` | In-place edit / tombstone | -| `chat_bulk_deleted` | `messages.bulkDeleteMessages` | Remove every purged row in one pass | -| `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` | -| `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator | -| `presence` / `member_update` / `user_update` | `members.*` | Live member-list update | -| `member_join` / `member_leave` / `member_ban` | `members.add/remove` | Member-list add/remove | -| `channel_create` / `channel_update` / `channel_delete` | `channels.*` | Sidebar update; redirect if the active channel was deleted | -| `roles_update` | `channels.setRoles` | Refresh name colors + permission-gated affordances | -| `emoji_update` | `emoji.setCustomEmoji` | Refresh picker, autocomplete, and rendered custom emoji | -| `voice_state` / `voice_leave` / `voice_config` / `voice_speakers` | `voice.*` | Voice roster + speaking rings | -| `voice_moved` / `voice_disconnected` | `voice.*` + `livekitSession` | Follow a mod move by rejoining the new channel / tear down after a mod kick with an error toast naming the reason | -| `voice_token` / `voice_e2ee_*` | `livekitSession.*` | Drive the voice-join + securing indicators | -| `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove | -| `server_restart` | `ui.setTransientError` | Restart banner with countdown | -| `error` | `ui.setTransientError` (+ `clearAuth` on `BANNED`) | Map the code → the reaction in §5 | +| Inbound event | Store effect | Target UI reaction | +| ------------------------------------------------------ | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `auth_ok` | `auth.setAuth` | Advance handshake → ready overlay | +| `auth_error` | `ui.setTransientError` + `auth.clearAuth` | Return to connect page with the reason shown | +| `ready` | bulk-load channels/roles/members/voice/dm | Render main view; resolve the connected overlay | +| `chat_message` | `messages.addMessage` (+ unread/DM/notify) | Append; reconcile a pending optimistic row if it's our echo | +| `chat_send_ok` | `messages.confirmSend` | Mark the optimistic row **sent** (see gap in [messaging.md](messaging.md)) | +| `chat_edited` / `chat_deleted` | `messages.editMessage` / `deleteMessage` | In-place edit / tombstone | +| `chat_bulk_deleted` | `messages.bulkDeleteMessages` | Remove every purged row in one pass | +| `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` | +| `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator | +| `presence` / `member_update` / `user_update` | `members.*` | Live member-list update | +| `member_join` / `member_ban` | `members.add/remove` | Member-list add/remove | +| `channel_create` / `channel_update` / `channel_delete` | `channels.*` | Sidebar update; redirect if the active channel was deleted | +| `roles_update` | `channels.setRoles` | Refresh name colors + permission-gated affordances | +| `emoji_update` | `emoji.setCustomEmoji` | Refresh picker, autocomplete, and rendered custom emoji | +| `voice_state` / `voice_leave` / `voice_config` | `voice.*` | Voice roster (speaking rings come from LiveKit's ActiveSpeakers, not the wire) | +| `voice_moved` / `voice_disconnected` | `voice.*` + `livekitSession` | Follow a mod move by rejoining the new channel / tear down after a mod kick with an error toast naming the reason | +| `voice_token` / `voice_e2ee_*` | `livekitSession.*` | Drive the voice-join + securing indicators | +| `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove | +| `server_restart` | `ui.setTransientError` | Restart banner with countdown | +| `error` | `ui.setTransientError` (+ `clearAuth` on `BANNED`) | Map the code → the reaction in §5 | `call_incoming` / `call_declined` are deliberately _not_ routed through the dispatcher: `MainPage.ts` subscribes to them directly (page-scoped listeners) diff --git a/docs/architecture/ux/channels-members-dms.md b/docs/architecture/ux/channels-members-dms.md index 08558de6..df272e0b 100644 --- a/docs/architecture/ux/channels-members-dms.md +++ b/docs/architecture/ux/channels-members-dms.md @@ -86,14 +86,14 @@ back on failure. Renders from `members.store` (`members` map + `typingUsers`). Shows presence and role grouping. -| State | Trigger | Target reaction | -| --------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `ready` | `ready.members` | Grouped by role, sorted; presence dot per member | -| `empty` | No online members | "No members online" (already the empty-state branch of `renderList()`, `components/MemberList.ts`) | -| presence change | `presence` event | Live dot update; offline members styled distinctly | -| role change | `member_update` | Re-group live | -| profile change | `user_update` | Name/avatar update; if it's us, also patch `auth.store` (already the `user_update` handler in `wireDispatcher()`, `lib/dispatcher.ts`) | -| join/leave/ban | `member_join`/`member_leave`/`member_ban` | Add/remove with no reflow flash | +| State | Trigger | Target reaction | +| --------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `ready` | `ready.members` | Grouped by role, sorted; presence dot per member | +| `empty` | No online members | "No members online" (already the empty-state branch of `renderList()`, `components/MemberList.ts`) | +| presence change | `presence` event | Live dot update; offline members styled distinctly | +| role change | `member_update` | Re-group live | +| profile change | `user_update` | Name/avatar update; if it's us, also patch `auth.store` (already the `user_update` handler in `wireDispatcher()`, `lib/dispatcher.ts`) | +| join/ban | `member_join`/`member_ban` | Add/remove with no reflow flash | ### 2.1 Typing indicator diff --git a/docs/architecture/ux/settings-and-admin.md b/docs/architecture/ux/settings-and-admin.md index 8ff62905..b4786c6f 100644 --- a/docs/architecture/ux/settings-and-admin.md +++ b/docs/architecture/ux/settings-and-admin.md @@ -100,16 +100,16 @@ The desktop client exposes a **subset** of admin operations inline, gated by the actor's role. Everything here must (a) only appear for users who can perform it, and (b) confirm destructive actions. -| Operation | Affordance | REST | Reaction | -| ---------------- | ------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------- | -| Change role | Member context menu → submenu | `PATCH /admin/api/users/{id}` `{role_id}` | Toast; `member_update` reflects live | -| Kick | Member menu, two-click confirm | `DELETE /admin/api/users/{id}/sessions` | Toast "Kicked {user}"; `member_leave` | -| Ban | Member menu, two-click confirm | `PATCH /admin/api/users/{id}` `{banned, ban_reason}` | Toast; `member_ban` removes them | -| Create channel | Sidebar → modal | `POST /admin/api/channels` | Modal closes on success; `channel_create` | -| Edit channel | Channel menu → modal | `PATCH /admin/api/channels/{id}` | `channel_update` | -| Delete channel | Channel menu, two-click confirm | `DELETE /admin/api/channels/{id}` | `channel_delete`; redirect if active | -| Reorder channels | Drag | `PATCH …/{id}` `{position}` per moved | Optimistic; roll back on failure | -| Invites | Invite manager modal | `GET/POST/DELETE /invites` | List with masked codes, copy, revoke; empty state "No active invites" | +| Operation | Affordance | REST | Reaction | +| ---------------- | ------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Change role | Member context menu → submenu | `PATCH /admin/api/users/{id}` `{role_id}` | Toast; `member_update` reflects live | +| Kick | Member menu, two-click confirm | `DELETE /admin/api/users/{id}/sessions` | Toast "Kicked {user}"; sessions revoked, sockets drop on the next sweep → `presence` offline | +| Ban | Member menu, two-click confirm | `PATCH /admin/api/users/{id}` `{banned, ban_reason}` | Toast; `member_ban` removes them | +| Create channel | Sidebar → modal | `POST /admin/api/channels` | Modal closes on success; `channel_create` | +| Edit channel | Channel menu → modal | `PATCH /admin/api/channels/{id}` | `channel_update` | +| Delete channel | Channel menu, two-click confirm | `DELETE /admin/api/channels/{id}` | `channel_delete`; redirect if active | +| Reorder channels | Drag | `PATCH …/{id}` `{position}` per moved | Optimistic; roll back on failure | +| Invites | Invite manager modal | `GET/POST/DELETE /invites` | List with masked codes, copy, revoke; empty state "No active invites" | **Target rules:** diff --git a/docs/architecture/ux/voice-and-e2ee.md b/docs/architecture/ux/voice-and-e2ee.md index dd80e7cd..7978848c 100644 --- a/docs/architecture/ux/voice-and-e2ee.md +++ b/docs/architecture/ux/voice-and-e2ee.md @@ -106,7 +106,7 @@ All four are optimistic with rollback; each also emits a WS control message. | listen-only | Badge "Listen only — no microphone" with a **Retry mic** affordance (`retryMicPermission`) | | camera on | Self video tile in the grid | | screenshare on | Screen tile; a stop-share affordance always visible | -| speaking | Green ring on the speaking user's tile/avatar (from `voice_speakers` / ActiveSpeakers) | +| speaking | Green ring on the speaking user's tile/avatar (from LiveKit's ActiveSpeakers) | **Mic-permission failure** (`restoreLocalVoiceState`): on denied/absent mic, set `listenOnly` and surface the specific reason ("Microphone permission denied" / @@ -139,7 +139,6 @@ reflects their `speaking/muted/deafened/camera/screenshare`. **Target:** | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `voice_state` | Add/update the participant with their flags | | `voice_leave` | Remove the tile; if it's us (kick/disconnect), clear local voice state (already the `voice_leave` handler in `wireDispatcher()`, `lib/dispatcher.ts`) | -| `voice_speakers` | Speaking ring on the listed users | | key-holder change | Invisible to users (re-election is automatic on leave); no UI churn | Per-user volume is adjustable and persisted (`userVolume_{id}` in the Rust store). diff --git a/docs/architecture/voice-e2ee.md b/docs/architecture/voice-e2ee.md index 82c44773..98f37e1a 100644 --- a/docs/architecture/voice-e2ee.md +++ b/docs/architecture/voice-e2ee.md @@ -66,7 +66,7 @@ Supporting pieces: `src/lib/screenShare.ts`, `src-tauri/src/livekit_proxy.rs` (tunnel), `src-tauri/src/ptt.rs` (push-to-talk key polling). -The wire flow (`voice_e2ee_announce` / `voice_e2ee_offer` / `voice_speakers`) +The wire flow (`voice_e2ee_announce` / `voice_e2ee_offer`) is specified in [protocol.md](../protocol.md) (Voice End-to-End Encryption section). Long-term identity: each user publishes an ECDSA identity public key (`users.identity_public_key`, migration 017); peers pin it on first contact diff --git a/docs/audit-2026-08-19.md b/docs/audit-2026-08-19.md index cdbce42b..d2ee3605 100644 --- a/docs/audit-2026-08-19.md +++ b/docs/audit-2026-08-19.md @@ -69,7 +69,7 @@ Every item left open by a prior audit, re-verified against `eacba10`. | T-2026-07-25 backlog #8: gofmt drift in `storage/storage.go` | LOW | **FIXED — but recurred elsewhere** | `gofmt -l Server/` is clean except one **new** drifted file, `admin/handlers_users_broadcast_test.go` (§6); no gofmt gate exists in CI or `.golangci.yml`, which is why it drifted in | | T-2026-07-25-17/-18/-19 (`main.go`/seed untested; `MainPage.ts`/`main.ts` excluded; no Go/Rust coverage floor) | LOW | **UNCHANGED, documented** | vitest excludes still carry written justifications (+1 justified entry: `noise-suppression.ts`); the rest are recorded accepted positions | | T-2026-07-25-20 `ws` flake under `-coverpkg` | LOW | **CAN'T VERIFY** | Not reproduced this session (plain `go test` used); stays on watch | -| DC-14 reserved protocol entries (`voice_speakers`, `member_leave`) | P3 | **STILL OPEN, deliberate** | Constants + schema entries exist, zero non-test emit sites; consistently documented "Reserved" in `protocol.md:1506,1508`; a protocol rev for the owner to schedule | +| DC-14 reserved protocol entries (`voice_speakers`, `member_leave`) | P3 | **Resolved 2026-08-28** — dropped in B2-1 (S-15); was "STILL OPEN, deliberate" | Constants + schema entries exist, zero non-test emit sites; consistently documented "Reserved" in `protocol.md:1506,1508`; a protocol rev for the owner to schedule | | `admin-e2e` soak graduation | P2 | **STILL PENDING** | `ci.yml:311` `continue-on-error: true`, graduation criterion recorded 2026-08-15 (~30 consecutive green main runs, `ci.yml:302-307`) | | 2026-07-19 §5: `tauri-build` never runs on push to `main` | LOW | **STILL TRUE** | `ci.yml:440-443` gates on `pull_request` + `base_ref == main`; only other desktop build is the `v*`-tag release workflow | | `livekitSession.ts` monolith | MED | **STILL OPEN, grew** | 1809 LOC (was 1719); satellites `roomEventHandlers.ts`/`livekitDiagnostics.ts` were added without shrinking the core. `AccountTab.ts` 1185, `SidebarArea.ts` 848, `LoginForm.ts` 784 | @@ -423,7 +423,7 @@ ledger has zero open findings. hand-mirrored visibility/permission copy. - **Protocol rev decision** (owner): emit-or-drop `voice_speakers` / `member_leave` (DC-14), and wire the plugin wire types into the client or - document desktop non-support (D-02's sibling). + document desktop non-support (D-02's sibling). DC-14 was decided 2026-08-28: dropped in B2-1 (S-15). - **Docs process hardening**: the 15-day re-drift (§4) says the per-PR rule alone doesn't hold; add the docs-check line to CI or the PR template checklist with teeth (a grep-able "docs reviewed" gate), plus the D-06 diff --git a/docs/plans/b2-protocol-trust-compat-2026-08-28.md b/docs/plans/b2-protocol-trust-compat-2026-08-28.md index 9f37709d..398a271a 100644 --- a/docs/plans/b2-protocol-trust-compat-2026-08-28.md +++ b/docs/plans/b2-protocol-trust-compat-2026-08-28.md @@ -194,6 +194,44 @@ B2-2 merges there is no clean way to record what "epoch 0/1" looked like. Verification: `npm run check:server` (regenerates and diffs both generators; runs the new test under `go test ./...`), `npm run check:client`. +**Evidence, 2026-08-28** — HP-2 question 1 cites this block: + +- Branch `feat/b2-1-epoch1-fixtures` from `dev` `fb6b51a0`; PR #1435 to `dev`. + Record the pre-squash head at merge time: + `gh api repos/J3vb/OwnCord/pulls/1435 --jq .head.sha` (before) or + `git ls-remote origin refs/pull/1435/head` (after). +- Pre-squash commits: retirement `dd638f1c` (own commit, before capture); + fixtures `54cae614` (capture), `c0719519` (end-of-journey barriers, + present-form optionals), `d5fe06e5` (null forms of `auth_ok`/`member_join` + user fields, `id` on `auth`, unsigned announce); client auth frame + `0f15fafb`; updater shapes `8e065130`; docs `00a7b65c`, `f7c161a5`, + `5b5b5c19`. +- Gates at `5b5b5c19`: `check:server`, `check:client`, `check:docs`, + `check:hygiene` all exit 0; `TestEpoch1Fixtures` passes `-count=3`, + `-race -count=3`, `-tags deadlock -count=10`; regeneration is + byte-identical; both trailing-frame guards proven by negative control. +- Item 4 grep at HEAD hits only `docs/audit-2026-07-19.md` (dated record) and + this file's own command text. +- Item 5 refined twice: the rule is **shape, not value** (a seeded default + value change is regenerated in the same PR; normalising those values was + rejected because it hides enum drift), and an epoch bump is for what older + clients cannot process — additive keys stay within the epoch and are + regenerated deliberately (Codex review on #1435; B2-2's negotiation fields + are the first such regeneration). Open for B2-2/B2-4: whether the epoch-1 + transcript should also replay with additive tolerance as the "old client + still works" check, and whether the captured wire is called epoch 0 (absent + `epoch`) or epoch 1 — this plan currently says both. +- Scope addition: six `docs/protocol.md` statements the captured wire proved + false were corrected in the same PR (relayed `voice_state` has no `seq`, + `chat_message.user.display_name`, the six real `auth_error` messages, + connect examples' `seq`, `voice_join` reply order, `voice_max_video` default + 25), plus the auth-failure close code 1008. +- Found, not fixed (behaviour change): the joiner's own `voice_state` is + broadcast through the hub queue while the rest of the join burst is written + directly (`Server/ws/voice_join.go:498` vs `:445`/`:523`/`:546`), so its + position on the joiner's socket is not guaranteed (~1/30 under + `-tags deadlock`). Documented; B2-8 / ledger candidate. + ## B2-2 — Protocol epoch and negotiation Design fixed 2026-08-28; the numbers and names below are the contract. diff --git a/docs/plans/discord-parity.md b/docs/plans/discord-parity.md index 27449168..8053fd95 100644 --- a/docs/plans/discord-parity.md +++ b/docs/plans/discord-parity.md @@ -118,12 +118,15 @@ slash commands (separate plan: `slash-commands.md`). - `sounds` table — the soundboard is absent wholesale, so the table has no feature to belong to; it and the client's `getSounds`/`deleteSound`, which still call unregistered routes, are the largest remaining piece. -- `voice_speakers` reserved WS type (never sent). - `voice_config.bitrate` — sent to clients, never applied client-side. - PTT stub on macOS. **Came off the list:** +- The `voice_speakers` reserved WS type (B2-1) — _retired rather than + implemented_. It was never sent, so it is gone from `protocol/schema.json`, + both generated constant sets and the client's dispatcher before the epoch-1 + wire fixtures froze the protocol. - `read_states.mention_count` (phase 3) — now written, shipped in `ready` and cleared by `channel_focus`. - The `emoji` table and the client's `getEmoji`/`deleteEmoji` (phase 6) — the diff --git a/docs/protocol.md b/docs/protocol.md index bf2096f3..1a17d8fc 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -91,15 +91,15 @@ The sequence number system enables reconnection with state recovery. ### Which Messages Get seq -| Category | Has seq? | Examples | -| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Channel broadcasts | Yes | `chat_message`, `chat_edited`, `chat_deleted`, `chat_bulk_deleted`, `reaction_update` | -| Global broadcasts | Yes | `member_join`, `member_leave`, `member_update`, `member_ban`, `roles_update`, `emoji_update`, `voice_state`, `voice_leave`, `channel_create`, `channel_update`, `channel_delete`, `server_restart` | -| Ephemeral | No | `typing`, `presence` from a `presence_update` (see below) | -| DM chat events | Yes | DM `chat_message`, `chat_edited`, `chat_deleted`, `reaction_update` — sequenced and replayable exactly like channel broadcasts, delivered only to the DM's participants | -| DM lifecycle | No | `dm_channel_open`, `dm_channel_close` | -| Call signalling | No | `call_incoming`, `call_declined` | -| Direct responses | No | `auth_ok`, `auth_error`, `chat_send_ok`, `error`, `voice_config`, `voice_token`, `pong` | +| Category | Has seq? | Examples | +| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Channel broadcasts | Yes | `chat_message`, `chat_edited`, `chat_deleted`, `chat_bulk_deleted`, `reaction_update` | +| Global broadcasts | Yes | `member_join`, `member_update`, `member_ban`, `roles_update`, `emoji_update`, `voice_state` (broadcast form; see below), `voice_leave`, `channel_create`, `channel_update`, `channel_delete`, `server_restart` | +| Ephemeral | No | `typing`, `presence` from a `presence_update` (see below) | +| DM chat events | Yes | DM `chat_message`, `chat_edited`, `chat_deleted`, `reaction_update` — sequenced and replayable exactly like channel broadcasts, delivered only to the DM's participants | +| DM lifecycle | No | `dm_channel_open`, `dm_channel_close` | +| Call signalling | No | `call_incoming`, `call_declined` | +| Direct responses | No | `auth_ok`, `auth_error`, `chat_send_ok`, `error`, `voice_config`, `voice_token`, `pong` | **`presence` is split, and only one half is sequenced.** Connect and disconnect presence is a normal sequenced global broadcast, so it replays on a warm resume. @@ -197,12 +197,18 @@ buffer), or `"db"` (persistent `events` table). See { "type": "auth_error", "payload": { - "message": "Invalid or expired token" + "message": "invalid token" } } ``` -After sending `auth_error`, the server closes the connection. +`message` is one of a fixed set the server actually sends: `invalid message` +(the first frame did not parse as JSON), `first message must be auth` (the +first frame was not type `auth`), `missing token`, `invalid token`, `session +expired`, and `user not found`. + +After sending `auth_error`, the server closes the connection with close code +**1008** (policy violation) and reason `authentication failed`. ### Step 4: ready Payload @@ -213,8 +219,8 @@ After `auth_ok`, the server sends a `ready` message containing all initial state The server broadcasts to all connected clients: ```json -{ "type": "member_join", "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" }, "status": "online" } } -{ "type": "presence", "payload": { "user_id": 1, "status": "online", "custom_status": null } } +{ "type": "member_join", "seq": 15, "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" }, "status": "online" } } +{ "type": "presence", "seq": 16, "payload": { "user_id": 1, "status": "online", "custom_status": null } } ``` ### Periodic Session Revalidation @@ -295,11 +301,12 @@ Sent once after `auth_ok` (fresh connection or replay fallback). **channels[]:** `id`, `name`, `type` (`text`/`voice`/`announcement`), `category`, `topic`, `position`, `can_send`, `slow_mode`, `nsfw`, `voice_max_users`, `voice_max_video`, `unread_count` (text + announcement), `last_message_id` (text + announcement), `mention_count` (text + announcement) `nsfw`, `voice_max_users` and `voice_max_video` are always present, with their -zero values (`false`, `0`, `0`) on an unconfigured channel — never omitted, so -"absent" never has to mean two different things. `nsfw` is a label the server -never acts on (see below); the two voice limits are the values the voice-join -path enforces with `CHANNEL_FULL` / `VIDEO_LIMIT`, shipped so a client can show -"3/5" and explain a refusal it could have predicted. +column defaults on an unconfigured channel — `false`, `0`, and **`25`** for +`voice_max_video`, which is `DEFAULT 25` rather than zero (migration 004) — +never omitted, so "absent" never has to mean two different things. `nsfw` is a +label the server never acts on (see below); the two voice limits are the values +the voice-join path enforces with `CHANNEL_FULL` / `VIDEO_LIMIT`, shipped so a +client can show "3/5" and explain a refusal it could have predicted. `mention_count` is the number of unread messages that mention this user — a direct `@username` or an authorized `@everyone`/`@here` — in that channel. It is @@ -382,7 +389,8 @@ Direct response to sender (no seq): "id": 1, "username": "alex", "avatar": "uuid.png", - "role": "admin" + "role": "admin", + "display_name": "Alex A." }, "content": "Hello everyone!", "reply_to": null, @@ -397,6 +405,9 @@ Direct response to sender (no seq): } ``` +`user.display_name` is the author's nickname to render instead of `username`; +present only when the author has one, omitted otherwise (see `member_join`). + | Field | Type | Description | | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- | | `mentions` | number[] | User IDs the server resolved from `@username` tokens. Always present; empty when nothing resolved. | @@ -894,12 +905,6 @@ be distinguishable from one that leaves it alone. and is omitted when none is published; peers that pinned a different key must surface a TOFU mismatch. -### member_leave (reserved) - -`member_leave` is a defined message type that the server does not currently -emit (clients handle it defensively). Reserved for future member-removal -flows. - --- ## Voice Signaling @@ -912,13 +917,17 @@ Voice uses LiveKit as the SFU. WebSocket messages handle signaling (join/leave/s { "type": "voice_join", "payload": { "channel_id": 10 } } ``` -On success, server sends (in order): +On success, server sends: 1. `voice_token` -- LiveKit JWT + URL 2. `voice_state` broadcast -- joiner's state to all clients 3. Existing `voice_state` messages -- one per existing participant (to joiner only) 4. `voice_config` -- channel audio settings (to joiner only) +Items 1, 3 and 4 are written directly and keep that relative order; item 2 +travels through the hub's broadcast queue, so its position relative to the +other three on the joiner's own socket is not guaranteed. + ### voice_token (Server -> Client, direct) ```json @@ -983,12 +992,6 @@ Quality presets: } ``` -### voice_speakers (reserved) - -`voice_speakers` (`{ channel_id, speakers: [user_id, ...], threshold_mode }`) -is a defined message type that the server does not currently emit; clients -already handle it. Reserved for active-speaker signaling. - ### voice_state (Server -> Client, broadcast) ```json @@ -1015,6 +1018,14 @@ Moderation](#voice-moderation)). `muted` / `deafened` are always set alongside them, so a client that ignores the two new fields still renders the user as silenced; they exist so the UI can show that the user may not lift it. +`voice_state` also arrives **unsequenced** in one case: when a client joins a +voice channel, the states of participants already in the room are relayed to +it directly, one message per participant (see the `voice_join` reply order +above) — those relayed copies carry no `seq` and are not replayed on resume. +Every other `voice_state` — a join, a leave, a mute/unmute, anything that +changes an existing participant's state — is the sequenced broadcast form +shown above. + ### voice_mute / voice_deafen (Client -> Server) ```json @@ -1555,9 +1566,7 @@ tables below add per-type behavioral notes. | `voice_disconnected` | No | Direct to disconnected user | | `voice_config` | No | Direct to joiner | | `voice_token` | No | Direct to joiner | -| `voice_speakers` | No | Reserved — not currently emitted | | `member_join` | Yes | All clients | -| `member_leave` | Yes | Reserved — not currently emitted | | `member_update` | Yes | All clients | | `user_update` | Yes | All clients (profile changes) | | `member_ban` | Yes | All clients | diff --git a/protocol/README.md b/protocol/README.md index e6ea9d1c..4cb4e09a 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -5,9 +5,10 @@ because neither side owns it: `schema.json` is the single source of truth for the message-type constants **both** the Go server and the TypeScript client compile against. -| File | Role | -| ------------- | --------------------------------------------------------- | -| `schema.json` | Source of truth. Every wire message type, both directions | +| File | Role | +| ------------------- | ---------------------------------------------------------------------------------------- | +| `schema.json` | Source of truth. Every wire message type, both directions | +| `fixtures/epoch-1/` | Frozen wire transcripts for protocol epoch 1. Regenerated by the test, never hand-edited | Two files are generated from it and must never be hand-edited: @@ -32,5 +33,56 @@ the constants independently — `.githooks/pre-commit`, `make protocol-verify` i CI, `npm run check:server`, and `Server/ws/protocol_contract_test.go`. There is nothing extra to run. +## Fixtures + +`fixtures/epoch-1/` holds one JSON file per journey (fresh connect, chat send, +voice join, ...). Within a file, each connection has its own list of frames in +order; frames on different connections are not related to each other — +cross-connection interleaving is timing-dependent and is not part of the +contract. Volatile values — ids, seqs, timestamps, tokens, user ids — are +replaced by typed placeholders such as `""`, so a refactor that +keeps the wire shape leaves the file untouched, while a renamed, added or +removed key, or a type change, shows up as a diff. + +`TestEpoch1Fixtures` in `Server/ws` drives the real server in-process and +compares its output against these files. It runs under `go test ./...`, so +`npm run check:server` and the `Server Build & Test` CI check already cover +it — there is nothing extra to run. + +Regenerate with: + +```bash +go test ./ws -run TestEpoch1Fixtures -update +``` + +from `Server/`, then read the diff frame by frame before committing it. + +**A fixture's shape may only change deliberately — and an epoch bump is for +what older clients cannot process.** Within an epoch, a change an older client +can ignore — a new key, a new optional field — is allowed: regenerate +`fixtures/epoch-/` in the same PR, read the diff frame by frame, and +document the addition in `docs/protocol.md`. A change an older client cannot +process — a key removed, renamed, or retyped, a frame dropped, or the order of +frames on one connection changed — needs a new `fixtures/epoch-/` +directory, with the old one kept as the record of what earlier clients speak. +A diff of that kind in a change that does not bump the epoch is a protocol +break to revert, not a refactor to accept. (Epoch negotiation itself is an +additive change and stays on epoch 1.) + +A diff confined to a **seeded default value** is not a protocol change. The +fixtures record real values wherever they are deterministic — role permission +masks and colours, `motd`, `server_name`, a channel's `voice_max_video`, the +`voice_config` preset — so a migration that changes a default mask moves a +fixture without touching the wire. Regenerate in the same PR and read the diff +frame by frame. Normalising those values away is not +the answer: a placeholder over a mask or over an enum such as +`voice_config.threshold_mode` would hide exactly the drift these files exist to +catch. + +A value drawn from a fixed vocabulary the client switches on — `threshold_mode`, +`quality`, `status`, `replay_source`, a channel `type` — is shape, not a seeded +value: renaming or dropping a member of it is a protocol change even though +only a value moved. + The narrative protocol reference is [`docs/protocol.md`](../docs/protocol.md); the blueprint is [`docs/architecture/websocket.md`](../docs/architecture/websocket.md). diff --git a/protocol/fixtures/epoch-1/auth-failure.json b/protocol/fixtures/epoch-1/auth-failure.json new file mode 100644 index 00000000..f6c9065d --- /dev/null +++ b/protocol/fixtures/epoch-1/auth-failure.json @@ -0,0 +1,27 @@ +{ + "journey": "auth-failure", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "last_seq": "", + "token": "" + }, + "type": "auth" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "message": "invalid token" + }, + "type": "auth_error" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/chat-edit-delete.json b/protocol/fixtures/epoch-1/chat-edit-delete.json new file mode 100644 index 00000000..f987130e --- /dev/null +++ b/protocol/fixtures/epoch-1/chat-edit-delete.json @@ -0,0 +1,110 @@ +{ + "journey": "chat-edit-delete", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "content": "edited text", + "message_id": "" + }, + "type": "chat_edit" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 1, + "content": "edited text", + "edited_at": "", + "mentions": [], + "mentions_everyone": false, + "mentions_here": false, + "message_id": "" + }, + "seq": "", + "type": "chat_edited" + } + }, + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "message_id": "" + }, + "type": "chat_delete" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 1, + "message_id": "" + }, + "seq": "", + "type": "chat_deleted" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ], + "b": [ + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 1, + "content": "edited text", + "edited_at": "", + "mentions": [], + "mentions_everyone": false, + "mentions_here": false, + "message_id": "" + }, + "seq": "", + "type": "chat_edited" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 1, + "message_id": "" + }, + "seq": "", + "type": "chat_deleted" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/chat-send-fanout.json b/protocol/fixtures/epoch-1/chat-send-fanout.json new file mode 100644 index 00000000..94b112a2 --- /dev/null +++ b/protocol/fixtures/epoch-1/chat-send-fanout.json @@ -0,0 +1,111 @@ +{ + "journey": "chat-send-fanout", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "channel_id": 1, + "content": "hello epoch one" + }, + "type": "chat_send" + } + }, + { + "dir": "s2c", + "frame": { + "id": "", + "payload": { + "message_id": "", + "timestamp": "" + }, + "type": "chat_send_ok" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "attachments": [], + "channel_id": 1, + "content": "hello epoch one", + "id": "", + "mentions": [], + "mentions_everyone": false, + "mentions_here": false, + "pinned": false, + "reactions": [], + "reply_to": null, + "timestamp": "", + "user": { + "avatar": "https://fixtures.invalid/alice-avatar.png", + "display_name": "Alice Fixture", + "id": "", + "role": "member", + "username": "alice" + } + }, + "seq": "", + "type": "chat_message" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ], + "b": [ + { + "dir": "s2c", + "frame": { + "payload": { + "attachments": [], + "channel_id": 1, + "content": "hello epoch one", + "id": "", + "mentions": [], + "mentions_everyone": false, + "mentions_here": false, + "pinned": false, + "reactions": [], + "reply_to": null, + "timestamp": "", + "user": { + "avatar": "https://fixtures.invalid/alice-avatar.png", + "display_name": "Alice Fixture", + "id": "", + "role": "member", + "username": "alice" + } + }, + "seq": "", + "type": "chat_message" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/dm-send.json b/protocol/fixtures/epoch-1/dm-send.json new file mode 100644 index 00000000..8c305c5e --- /dev/null +++ b/protocol/fixtures/epoch-1/dm-send.json @@ -0,0 +1,111 @@ +{ + "journey": "dm-send", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "channel_id": 3, + "content": "hello over dm" + }, + "type": "chat_send" + } + }, + { + "dir": "s2c", + "frame": { + "id": "", + "payload": { + "message_id": "", + "timestamp": "" + }, + "type": "chat_send_ok" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "attachments": [], + "channel_id": 3, + "content": "hello over dm", + "id": "", + "mentions": [], + "mentions_everyone": false, + "mentions_here": false, + "pinned": false, + "reactions": [], + "reply_to": null, + "timestamp": "", + "user": { + "avatar": "https://fixtures.invalid/alice-avatar.png", + "display_name": "Alice Fixture", + "id": "", + "role": "member", + "username": "alice" + } + }, + "seq": "", + "type": "chat_message" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ], + "b": [ + { + "dir": "s2c", + "frame": { + "payload": { + "attachments": [], + "channel_id": 3, + "content": "hello over dm", + "id": "", + "mentions": [], + "mentions_everyone": false, + "mentions_here": false, + "pinned": false, + "reactions": [], + "reply_to": null, + "timestamp": "", + "user": { + "avatar": "https://fixtures.invalid/alice-avatar.png", + "display_name": "Alice Fixture", + "id": "", + "role": "member", + "username": "alice" + } + }, + "seq": "", + "type": "chat_message" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/fresh-connect.json b/protocol/fixtures/epoch-1/fresh-connect.json new file mode 100644 index 00000000..3afd660e --- /dev/null +++ b/protocol/fixtures/epoch-1/fresh-connect.json @@ -0,0 +1,381 @@ +{ + "journey": "fresh-connect", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "last_seq": "", + "token": "" + }, + "type": "auth" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "motd": "Welcome!", + "replay_source": "none", + "server_name": "OwnCord Server", + "user": { + "about": "fixture profile text", + "avatar": "https://fixtures.invalid/alice-avatar.png", + "custom_status": "fixture custom status", + "display_name": "Alice Fixture", + "id": "", + "role": "member", + "status": "online", + "username": "alice" + } + }, + "type": "auth_ok" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channels": [ + { + "can_send": true, + "category": "", + "id": "", + "last_message_id": "", + "mention_count": 0, + "name": "general", + "nsfw": false, + "position": 0, + "slow_mode": 0, + "topic": "", + "type": "text", + "unread_count": 0, + "voice_max_users": 0, + "voice_max_video": 25 + }, + { + "can_send": true, + "category": "", + "id": "", + "name": "Voice", + "nsfw": false, + "position": 1, + "slow_mode": 0, + "topic": "", + "type": "voice", + "voice_max_users": 0, + "voice_max_video": 25 + } + ], + "dm_channels": [], + "members": [ + { + "avatar": "https://fixtures.invalid/alice-avatar.png", + "custom_status": "fixture custom status", + "display_name": "Alice Fixture", + "id": "", + "identity_public_key": "YWxpY2UtaWRlbnRpdHktcHVibGljLWtleS1maXh0dXJl", + "role": "member", + "status": "online", + "username": "alice" + }, + { + "avatar": null, + "custom_status": null, + "display_name": null, + "id": "", + "role": "member", + "status": "offline", + "username": "bob" + } + ], + "motd": "Welcome!", + "roles": [ + { + "color": "#E74C3C", + "id": "", + "is_default": false, + "name": "Owner", + "permissions": 2147483647, + "position": 100 + }, + { + "color": "#F39C12", + "id": "", + "is_default": false, + "name": "Admin", + "permissions": 1073741823, + "position": 80 + }, + { + "color": "#3498DB", + "id": "", + "is_default": false, + "name": "Moderator", + "permissions": 3145727, + "position": 60 + }, + { + "color": null, + "id": "", + "is_default": true, + "name": "Member", + "permissions": 7779, + "position": 40 + } + ], + "server_name": "OwnCord Server", + "voice_states": [] + }, + "type": "ready" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "status": "online", + "user": { + "avatar": "https://fixtures.invalid/alice-avatar.png", + "display_name": "Alice Fixture", + "id": "", + "identity_public_key": "YWxpY2UtaWRlbnRpdHktcHVibGljLWtleS1maXh0dXJl", + "role": "member", + "username": "alice" + } + }, + "seq": "", + "type": "member_join" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "custom_status": "fixture custom status", + "status": "online", + "user_id": "" + }, + "seq": "", + "type": "presence" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "status": "online", + "user": { + "avatar": null, + "id": "", + "role": "member", + "username": "bob" + } + }, + "seq": "", + "type": "member_join" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "custom_status": null, + "status": "online", + "user_id": "" + }, + "seq": "", + "type": "presence" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ], + "b": [ + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "last_seq": "", + "token": "" + }, + "type": "auth" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "motd": "Welcome!", + "replay_source": "none", + "server_name": "OwnCord Server", + "user": { + "about": null, + "avatar": null, + "custom_status": null, + "display_name": null, + "id": "", + "role": "member", + "status": "online", + "username": "bob" + } + }, + "type": "auth_ok" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channels": [ + { + "can_send": true, + "category": "", + "id": "", + "last_message_id": "", + "mention_count": 0, + "name": "general", + "nsfw": false, + "position": 0, + "slow_mode": 0, + "topic": "", + "type": "text", + "unread_count": 0, + "voice_max_users": 0, + "voice_max_video": 25 + }, + { + "can_send": true, + "category": "", + "id": "", + "name": "Voice", + "nsfw": false, + "position": 1, + "slow_mode": 0, + "topic": "", + "type": "voice", + "voice_max_users": 0, + "voice_max_video": 25 + } + ], + "dm_channels": [], + "members": [ + { + "avatar": "https://fixtures.invalid/alice-avatar.png", + "custom_status": "fixture custom status", + "display_name": "Alice Fixture", + "id": "", + "identity_public_key": "YWxpY2UtaWRlbnRpdHktcHVibGljLWtleS1maXh0dXJl", + "role": "member", + "status": "online", + "username": "alice" + }, + { + "avatar": null, + "custom_status": null, + "display_name": null, + "id": "", + "role": "member", + "status": "online", + "username": "bob" + } + ], + "motd": "Welcome!", + "roles": [ + { + "color": "#E74C3C", + "id": "", + "is_default": false, + "name": "Owner", + "permissions": 2147483647, + "position": 100 + }, + { + "color": "#F39C12", + "id": "", + "is_default": false, + "name": "Admin", + "permissions": 1073741823, + "position": 80 + }, + { + "color": "#3498DB", + "id": "", + "is_default": false, + "name": "Moderator", + "permissions": 3145727, + "position": 60 + }, + { + "color": null, + "id": "", + "is_default": true, + "name": "Member", + "permissions": 7779, + "position": 40 + } + ], + "server_name": "OwnCord Server", + "voice_states": [] + }, + "type": "ready" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "status": "online", + "user": { + "avatar": null, + "id": "", + "role": "member", + "username": "bob" + } + }, + "seq": "", + "type": "member_join" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "custom_status": null, + "status": "online", + "user_id": "" + }, + "seq": "", + "type": "presence" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/mark-read.json b/protocol/fixtures/epoch-1/mark-read.json new file mode 100644 index 00000000..7e9e33c8 --- /dev/null +++ b/protocol/fixtures/epoch-1/mark-read.json @@ -0,0 +1,29 @@ +{ + "journey": "mark-read", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "payload": { + "channel_id": 1 + }, + "type": "mark_read" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/ping.json b/protocol/fixtures/epoch-1/ping.json new file mode 100644 index 00000000..149eec43 --- /dev/null +++ b/protocol/fixtures/epoch-1/ping.json @@ -0,0 +1,33 @@ +{ + "journey": "ping", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/reaction-add-remove.json b/protocol/fixtures/epoch-1/reaction-add-remove.json new file mode 100644 index 00000000..293bbc1f --- /dev/null +++ b/protocol/fixtures/epoch-1/reaction-add-remove.json @@ -0,0 +1,113 @@ +{ + "journey": "reaction-add-remove", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "emoji": "👍", + "message_id": "" + }, + "type": "reaction_add" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "action": "add", + "channel_id": 1, + "emoji": "👍", + "message_id": "", + "user_id": "" + }, + "seq": "", + "type": "reaction_update" + } + }, + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "emoji": "👍", + "message_id": "" + }, + "type": "reaction_remove" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "action": "remove", + "channel_id": 1, + "emoji": "👍", + "message_id": "", + "user_id": "" + }, + "seq": "", + "type": "reaction_update" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ], + "b": [ + { + "dir": "s2c", + "frame": { + "payload": { + "action": "add", + "channel_id": 1, + "emoji": "👍", + "message_id": "", + "user_id": "" + }, + "seq": "", + "type": "reaction_update" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "action": "remove", + "channel_id": 1, + "emoji": "👍", + "message_id": "", + "user_id": "" + }, + "seq": "", + "type": "reaction_update" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/resume-replay.json b/protocol/fixtures/epoch-1/resume-replay.json new file mode 100644 index 00000000..930d8929 --- /dev/null +++ b/protocol/fixtures/epoch-1/resume-replay.json @@ -0,0 +1,178 @@ +{ + "journey": "resume-replay", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "active_channel_id": "", + "last_seq": "", + "token": "" + }, + "type": "auth" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "motd": "Welcome!", + "replay_source": "buffer", + "server_name": "OwnCord Server", + "user": { + "about": "fixture profile text", + "avatar": "https://fixtures.invalid/alice-avatar.png", + "custom_status": "fixture custom status", + "display_name": "Alice Fixture", + "id": "", + "role": "member", + "status": "online", + "username": "alice" + } + }, + "type": "auth_ok" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "custom_status": null, + "status": "offline", + "user_id": "" + }, + "seq": "", + "type": "presence" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "attachments": [], + "channel_id": 1, + "content": "sent while away", + "id": "", + "mentions": [], + "mentions_everyone": false, + "mentions_here": false, + "pinned": false, + "reactions": [], + "reply_to": null, + "timestamp": "", + "user": { + "avatar": null, + "id": "", + "role": "member", + "username": "bob" + } + }, + "seq": "", + "type": "chat_message" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "custom_status": "fixture custom status", + "status": "online", + "user_id": "" + }, + "seq": "", + "type": "presence" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ], + "b": [ + { + "dir": "s2c", + "frame": { + "payload": { + "custom_status": null, + "status": "offline", + "user_id": "" + }, + "seq": "", + "type": "presence" + } + }, + { + "dir": "c2s", + "frame": { + "id": "", + "payload": { + "channel_id": 1, + "content": "sent while away" + }, + "type": "chat_send" + } + }, + { + "dir": "s2c", + "frame": { + "id": "", + "payload": { + "message_id": "", + "timestamp": "" + }, + "type": "chat_send_ok" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "attachments": [], + "channel_id": 1, + "content": "sent while away", + "id": "", + "mentions": [], + "mentions_everyone": false, + "mentions_here": false, + "pinned": false, + "reactions": [], + "reply_to": null, + "timestamp": "", + "user": { + "avatar": null, + "id": "", + "role": "member", + "username": "bob" + } + }, + "seq": "", + "type": "chat_message" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/typing.json b/protocol/fixtures/epoch-1/typing.json new file mode 100644 index 00000000..59e03571 --- /dev/null +++ b/protocol/fixtures/epoch-1/typing.json @@ -0,0 +1,55 @@ +{ + "journey": "typing", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "payload": { + "channel_id": 1 + }, + "type": "typing_start" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ], + "b": [ + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 1, + "user_id": "", + "username": "alice" + }, + "type": "typing" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/fixtures/epoch-1/voice-join-e2ee-leave.json b/protocol/fixtures/epoch-1/voice-join-e2ee-leave.json new file mode 100644 index 00000000..a4747785 --- /dev/null +++ b/protocol/fixtures/epoch-1/voice-join-e2ee-leave.json @@ -0,0 +1,256 @@ +{ + "journey": "voice-join-e2ee-leave", + "connections": { + "a": [ + { + "dir": "c2s", + "frame": { + "payload": { + "channel_id": 2 + }, + "type": "voice_join" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 2, + "direct_url": "ws://localhost:7880", + "is_key_holder": true, + "token": "", + "url": "/livekit" + }, + "type": "voice_token" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "bitrate": 64000, + "channel_id": 2, + "max_users": 0, + "mixing_threshold": 0, + "quality": "medium", + "threshold_mode": "top_speakers", + "top_speakers": 5 + }, + "type": "voice_config" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "camera": false, + "channel_id": 2, + "deafened": false, + "muted": false, + "screenshare": false, + "server_deafened": false, + "server_muted": false, + "speaking": false, + "user_id": "", + "username": "bob" + }, + "seq": "", + "type": "voice_state" + } + }, + { + "dir": "c2s", + "frame": { + "payload": { + "public_key": "YWxpY2UtZWNkaC1wdWJsaWMta2V5LWZpeHR1cmU=", + "signature": "YWxpY2Utc2lnbmF0dXJlLW92ZXItaGVyLWVjZGgta2V5" + }, + "type": "voice_e2ee_announce" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "public_key": "Ym9iLWVjZGgtcHVibGljLWtleS1maXh0dXJl", + "user_id": "" + }, + "type": "voice_e2ee_announce" + } + }, + { + "dir": "c2s", + "frame": { + "payload": { + "encrypted_key": "ZW5jcnlwdGVkLXJvb20ta2V5LWZpeHR1cmU=", + "iv": "aXYtZml4dHVyZS0xMg==", + "target_user_id": "" + }, + "type": "voice_e2ee_offer" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "voice_leave" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 2, + "user_id": "" + }, + "seq": "", + "type": "voice_leave" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ], + "b": [ + { + "dir": "s2c", + "frame": { + "payload": { + "camera": false, + "channel_id": 2, + "deafened": false, + "muted": false, + "screenshare": false, + "server_deafened": false, + "server_muted": false, + "speaking": false, + "user_id": "", + "username": "alice" + }, + "seq": "", + "type": "voice_state" + } + }, + { + "dir": "c2s", + "frame": { + "payload": { + "channel_id": 2 + }, + "type": "voice_join" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 2, + "direct_url": "ws://localhost:7880", + "is_key_holder": false, + "token": "", + "url": "/livekit" + }, + "type": "voice_token" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "camera": false, + "channel_id": 2, + "deafened": false, + "muted": false, + "screenshare": false, + "server_deafened": false, + "server_muted": false, + "speaking": false, + "user_id": "", + "username": "alice" + }, + "type": "voice_state" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "bitrate": 64000, + "channel_id": 2, + "max_users": 0, + "mixing_threshold": 0, + "quality": "medium", + "threshold_mode": "top_speakers", + "top_speakers": 5 + }, + "type": "voice_config" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "public_key": "YWxpY2UtZWNkaC1wdWJsaWMta2V5LWZpeHR1cmU=", + "signature": "YWxpY2Utc2lnbmF0dXJlLW92ZXItaGVyLWVjZGgta2V5", + "user_id": "" + }, + "type": "voice_e2ee_announce" + } + }, + { + "dir": "c2s", + "frame": { + "payload": { + "public_key": "Ym9iLWVjZGgtcHVibGljLWtleS1maXh0dXJl" + }, + "type": "voice_e2ee_announce" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "encrypted_key": "ZW5jcnlwdGVkLXJvb20ta2V5LWZpeHR1cmU=", + "from_user_id": "", + "iv": "aXYtZml4dHVyZS0xMg==" + }, + "type": "voice_e2ee_offer" + } + }, + { + "dir": "s2c", + "frame": { + "payload": { + "channel_id": 2, + "user_id": "" + }, + "seq": "", + "type": "voice_leave" + } + }, + { + "dir": "c2s", + "frame": { + "payload": {}, + "type": "ping" + } + }, + { + "dir": "s2c", + "frame": { + "type": "pong" + } + } + ] + } +} diff --git a/protocol/schema.json b/protocol/schema.json index a9526d73..d8c06153 100644 --- a/protocol/schema.json +++ b/protocol/schema.json @@ -62,7 +62,6 @@ { "wire": "voice_state", "go": "MsgTypeVoiceState", "ts": "VOICE_STATE" }, { "wire": "voice_config", "go": "MsgTypeVoiceConfig", "ts": "VOICE_CONFIG" }, { "wire": "voice_token", "go": "MsgTypeVoiceToken", "ts": "VOICE_TOKEN" }, - { "wire": "voice_speakers", "go": "MsgTypeVoiceSpeakers", "ts": "VOICE_SPEAKERS" }, { "wire": "voice_leave", "go": "MsgTypeVoiceLeaveBC", @@ -72,7 +71,6 @@ { "wire": "voice_moved", "go": "MsgTypeVoiceMoved", "ts": "VOICE_MOVED" }, { "wire": "voice_disconnected", "go": "MsgTypeVoiceDisconnected", "ts": "VOICE_DISCONNECTED" }, { "wire": "member_join", "go": "MsgTypeMemberJoin", "ts": "MEMBER_JOIN" }, - { "wire": "member_leave", "go": "MsgTypeMemberLeave", "ts": "MEMBER_LEAVE" }, { "wire": "member_update", "go": "MsgTypeMemberUpdate", "ts": "MEMBER_UPDATE" }, { "wire": "user_update", "go": "MsgTypeUserUpdate", "ts": "USER_UPDATE" }, { "wire": "member_ban", "go": "MsgTypeMemberBan", "ts": "MEMBER_BAN" },