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" },