test(b2-1): capture the epoch-1 protocol fixtures and retire S-15 (#1435)

* refactor(protocol): retire reserved voice_speakers and member_leave (S-15)

Neither type was ever emitted by the server; B2-1 clears them from the
schema before the epoch-1 wire fixtures are captured, so the frozen epoch
does not carry two dead message types.

Client: dropped the dead `ws.on(MEMBER_LEAVE)` / `ws.on(VOICE_SPEAKERS)`
dispatcher handlers, the `MemberLeavePayload` type and both `ServerMessage`
union members, and the tests that only exercised those WS paths.
`removeMember` (member_ban) and `setSpeakers` (LiveKit ActiveSpeakers) stay
live and keep their direct unit tests.

* test(ws): capture the epoch-1 wire fixtures

alpha.4 is the last client on the pre-epoch wire and B2-2 adds a protocol
epoch to the auth handshake next, so record what epoch 1 actually looks
like while it is still observable.

TestEpoch1Fixtures drives eleven journeys through the ws package's
in-process hub harness (full migrations, real hub, httptest WebSocket
server) and compares each journey's per-connection frame sequence with a
transcript under protocol/fixtures/epoch-1/:

- fresh-connect, auth-failure, ping
- chat-send-fanout, chat-edit-delete, reaction-add-remove
- typing, mark-read, dm-send
- resume-replay (last_seq + buffer-tier replay burst)
- voice-join-e2ee-leave (join, both voice_state forms, announce and
  offer relay, leave)

Volatile values are replaced by typed placeholders before both writing
and comparison -- any key that is id/seq/last_seq, ends in _id (except
channel_id and role_id) or _at, is timestamp/ts/last_seen, or contains
token, becomes "<class:json-type>" so a field that changes type is still
a diff, while everything else is compared verbatim.

Regenerate with: go test ./ws -run TestEpoch1Fixtures -update

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(client): pin the epoch-1 auth frame contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(updater): pin the epoch-1 manifest and client-update shapes

* test(ws): harden the epoch-1 fixtures against trailing frames and absent optionals

Every journey now ends with the ping/pong barrier on each recorded
connection, so a frame the server emits after the last read fails as
`expected "pong", got "X"` instead of going unrecorded; auth-failure
asserts the StatusPolicyViolation close instead, its socket being gone.

alice carries a display name, avatar, about text, custom status, identity
public key and an announce signature (bob carries none), so every optional
field is frozen in its present form as well as its absent one — a rename or
a retype of display_name or identity_public_key now moves a fixture.

The typing journey focuses the channel on "a" before typing: without the
subscription registerNow only makes for a focused client, its ping/pong
proved nothing about excludeUserID.

Comment fixes: the escaped placeholder form MarshalIndent would write, the
real (headroom) reason for the raised read limit, a note that bare id and
active_channel_id are normalised by design, and a .prettierignore line
saying these fixtures are verified by the Go comparison, not by git diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(protocol): document the epoch-1 fixtures

* docs(protocol): match the epoch-1 wire where the fixtures contradicted the doc

- voice_state: note the unsequenced relay-to-joiner form (table row + section)
- auth_error: correct the example message and list the real rejection messages
- member_join/presence connect example: add the seq both frames actually carry
- chat_message.user: document display_name
- voice_join reply order: state that the joiner's own voice_state broadcast is not ordered against the other three frames

* test(ws): freeze the null forms of auth_ok and member_join user fields

buildAuthOK emits display_name/about/custom_status/avatar as
always-present nulls, but only alice — who has all four set — ever
authenticated on a recorded connection, so the fixtures froze those
fields in their populated form alone. A rename, a retype or a dropped
null would have moved nothing, on the very frame B2-2 edits.

fresh-connect now records bob's handshake too, on a second connection:
his auth_ok carries the four nulls, his member_join carries avatar null
with display_name and identity_public_key omitted, and alice — idle by
then, so her reads stay in hub order — records the same pair as an
already-connected observer sees it. In voice-join-e2ee-leave bob answers
alice's signed announce with a legacy unsigned one, which freezes the
absent form of signature next to her present one. The auth frames the
test writes now carry the correlation id the real client stamps on every
frame (ws.ts send()); normalisation renders it <id:string>.

expectClosed also asserts the close reason ("authentication failed"),
not just code 1008 — HP-2 asks for both.

Comment precision, no behaviour change: the barrier guarantee now states
that pong may overtake a pending LOW-priority frame (writePump) and that
no journey is affected because every barrier is sent on an idle
connection; the ping-budget ceiling is six connections, not four; the
typing journey cites handleChannelFocusV2 rather than registerNow as the
subscribe site; resume-replay's b barrier explains why moving it past
the resume would be a flake, not a fix; and the header notes that
normalisation hides that chat_send_ok.id echoes the request id.

The client contract test's cited range for ws.ts's send() call is
441-453, not 441-454.

Regenerate with: go test ./ws -run TestEpoch1Fixtures -update

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(protocol): shape-not-value fixture rule, voice_max_video default, auth close code

The fixture rule read "a fixture may only change with an epoch bump",
which is false for the seeded values the transcripts record verbatim:
role permission masks and colours, motd, server_name, voice_max_video,
the voice_config preset. A migration that changes a default mask diffs
fresh-connect.json, and the README told the author to revert a change
that never touched the wire.

Split the rule along shape versus value. A key set, a JSON type, a key
appearing or disappearing, or per-connection frame order is a protocol
change and earns fixtures/epoch-<n+1>/. A seeded default value is a seed
change: regenerate in the same PR and read the diff frame by frame.
Normalising those values is explicitly not the answer — a placeholder
over a mask or over an enum such as voice_config.threshold_mode would
hide the drift the fixtures exist to catch.

Two wire facts corrected against the fixtures:

- voice_max_video on an unconfigured channel is 25, not 0 (migration
  004 is DEFAULT 25); the doc listed it among the zero values.
- auth_error is followed by a close with code 1008 (policy violation)
  and reason "authentication failed" (serve.go:128), which the doc left
  as "closes the connection".

Also: voice_speakers moves from discord-parity's "still dead" list to
"came off the list" — it was retired earlier on this branch. And Kick
says sessions are revoked and sockets drop on the next sweep, which is
what ForceLogout does (moderation.go:236); it does not cut sockets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(protocol): fixture rule covers enum vocabularies; header comment precision

Re-check minors from the whole-branch review: a fixed-vocabulary value the client switches on is shape, not a seeded value; drop the false 'new default channel' clause (the seed asserts channel ids); the low-priority frame is presence_update, not connect presence.

* docs(plans): record B2-1 evidence for HP-2 (PR #1435, fixture commit SHAs)

* docs(protocol): additive changes stay within an epoch; bump only for what old clients cannot process

Codex review on #1435: B2-2 keeps protocol_epoch = 1 while adding auth/ready/auth_error fields, which the previous wording would have called a break to revert. An epoch is a compatibility boundary, not a snapshot: additive keys regenerate in the same PR and are documented; removals, renames, retypes, dropped frames and reordering bump the epoch. The plan's B2-1 evidence records the refinement and hands B2-2/B2-4 the open questions (additive-tolerant replay of the epoch-1 transcript; epoch 0 vs 1 naming).

* test(client): compare auth-frame key sets order-independently

Codex review on #1435: Object.keys preserves insertion order, so a harmless property reorder in ws.ts would fail the pin. Key order has no wire meaning; the Go fixtures already compare with sorted keys. Sort both sides.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-28 15:43:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fb6b51a062
commit 1fe3df7962
38 changed files with 3064 additions and 309 deletions
+4
View File
@@ -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
-14
View File
@@ -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 }) =>
-2
View File
@@ -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",
+3 -7
View File
@@ -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<VoiceStatePayload> & { readonly type: "voice_state" })
| (WsEnvelope<VoiceLeavePayload> & { readonly type: "voice_leave" })
| (WsEnvelope<VoiceConfigPayload> & { readonly type: "voice_config" })
| (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" })
| (WsEnvelope<VoiceTokenPayload> & { readonly type: "voice_token" })
| (WsEnvelope<VoiceMovedPayload> & { readonly type: "voice_moved" })
| (WsEnvelope<VoiceDisconnectedPayload> & { readonly type: "voice_disconnected" })
| (WsEnvelope<VoiceE2EEAnnouncePayload> & { readonly type: "voice_e2ee_announce" })
| (WsEnvelope<VoiceE2EEOfferPayload> & { readonly type: "voice_e2ee_offer" })
| (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" })
| (WsEnvelope<MemberLeavePayload> & { readonly type: "member_leave" })
| (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" })
| (WsEnvelope<UserUpdatePayload> & { readonly type: "user_update" })
| (WsEnvelope<MemberBanPayload> & { readonly type: "member_ban" })
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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);
+143
View File
@@ -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<string, unknown> } {
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<string, unknown>;
};
}
describe("contract: auth frame key set (epoch 1)", () => {
let client: ReturnType<typeof createWsClient>;
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);
});
});
+7 -55
View File
@@ -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']");
+2 -71
View File
@@ -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" },
-49
View File
@@ -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");
+7 -17
View File
@@ -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",
+3 -3
View File
@@ -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);
});
+70
View File
@@ -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())
}
}
+74
View File
@@ -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)
}
}
-2
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+22 -22
View File
@@ -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)
+8 -8
View File
@@ -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
+10 -10
View File
@@ -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:**
+1 -2
View File
@@ -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).
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
@@ -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.
+4 -1
View File
@@ -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
+43 -34
View File
@@ -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 |
+55 -3
View File
@@ -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 `"<id:number>"`, 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-<n>/` 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-<n+1>/`
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).
@@ -0,0 +1,27 @@
{
"journey": "auth-failure",
"connections": {
"a": [
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"last_seq": "<seq:number>",
"token": "<token:string>"
},
"type": "auth"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"message": "invalid token"
},
"type": "auth_error"
}
}
]
}
}
@@ -0,0 +1,110 @@
{
"journey": "chat-edit-delete",
"connections": {
"a": [
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"content": "edited text",
"message_id": "<id:number>"
},
"type": "chat_edit"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"channel_id": 1,
"content": "edited text",
"edited_at": "<ts:string>",
"mentions": [],
"mentions_everyone": false,
"mentions_here": false,
"message_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "chat_edited"
}
},
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"message_id": "<id:number>"
},
"type": "chat_delete"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"channel_id": 1,
"message_id": "<id:number>"
},
"seq": "<seq:number>",
"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": "<ts:string>",
"mentions": [],
"mentions_everyone": false,
"mentions_here": false,
"message_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "chat_edited"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"channel_id": 1,
"message_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "chat_deleted"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
]
}
}
@@ -0,0 +1,111 @@
{
"journey": "chat-send-fanout",
"connections": {
"a": [
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"channel_id": 1,
"content": "hello epoch one"
},
"type": "chat_send"
}
},
{
"dir": "s2c",
"frame": {
"id": "<id:string>",
"payload": {
"message_id": "<id:number>",
"timestamp": "<ts:string>"
},
"type": "chat_send_ok"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"attachments": [],
"channel_id": 1,
"content": "hello epoch one",
"id": "<id:number>",
"mentions": [],
"mentions_everyone": false,
"mentions_here": false,
"pinned": false,
"reactions": [],
"reply_to": null,
"timestamp": "<ts:string>",
"user": {
"avatar": "https://fixtures.invalid/alice-avatar.png",
"display_name": "Alice Fixture",
"id": "<id:number>",
"role": "member",
"username": "alice"
}
},
"seq": "<seq:number>",
"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": "<id:number>",
"mentions": [],
"mentions_everyone": false,
"mentions_here": false,
"pinned": false,
"reactions": [],
"reply_to": null,
"timestamp": "<ts:string>",
"user": {
"avatar": "https://fixtures.invalid/alice-avatar.png",
"display_name": "Alice Fixture",
"id": "<id:number>",
"role": "member",
"username": "alice"
}
},
"seq": "<seq:number>",
"type": "chat_message"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
]
}
}
+111
View File
@@ -0,0 +1,111 @@
{
"journey": "dm-send",
"connections": {
"a": [
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"channel_id": 3,
"content": "hello over dm"
},
"type": "chat_send"
}
},
{
"dir": "s2c",
"frame": {
"id": "<id:string>",
"payload": {
"message_id": "<id:number>",
"timestamp": "<ts:string>"
},
"type": "chat_send_ok"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"attachments": [],
"channel_id": 3,
"content": "hello over dm",
"id": "<id:number>",
"mentions": [],
"mentions_everyone": false,
"mentions_here": false,
"pinned": false,
"reactions": [],
"reply_to": null,
"timestamp": "<ts:string>",
"user": {
"avatar": "https://fixtures.invalid/alice-avatar.png",
"display_name": "Alice Fixture",
"id": "<id:number>",
"role": "member",
"username": "alice"
}
},
"seq": "<seq:number>",
"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": "<id:number>",
"mentions": [],
"mentions_everyone": false,
"mentions_here": false,
"pinned": false,
"reactions": [],
"reply_to": null,
"timestamp": "<ts:string>",
"user": {
"avatar": "https://fixtures.invalid/alice-avatar.png",
"display_name": "Alice Fixture",
"id": "<id:number>",
"role": "member",
"username": "alice"
}
},
"seq": "<seq:number>",
"type": "chat_message"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
]
}
}
@@ -0,0 +1,381 @@
{
"journey": "fresh-connect",
"connections": {
"a": [
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"last_seq": "<seq:number>",
"token": "<token:string>"
},
"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": "<id:number>",
"role": "member",
"status": "online",
"username": "alice"
}
},
"type": "auth_ok"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"channels": [
{
"can_send": true,
"category": "",
"id": "<id:number>",
"last_message_id": "<id:number>",
"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": "<id:number>",
"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": "<id:number>",
"identity_public_key": "YWxpY2UtaWRlbnRpdHktcHVibGljLWtleS1maXh0dXJl",
"role": "member",
"status": "online",
"username": "alice"
},
{
"avatar": null,
"custom_status": null,
"display_name": null,
"id": "<id:number>",
"role": "member",
"status": "offline",
"username": "bob"
}
],
"motd": "Welcome!",
"roles": [
{
"color": "#E74C3C",
"id": "<id:number>",
"is_default": false,
"name": "Owner",
"permissions": 2147483647,
"position": 100
},
{
"color": "#F39C12",
"id": "<id:number>",
"is_default": false,
"name": "Admin",
"permissions": 1073741823,
"position": 80
},
{
"color": "#3498DB",
"id": "<id:number>",
"is_default": false,
"name": "Moderator",
"permissions": 3145727,
"position": 60
},
{
"color": null,
"id": "<id:number>",
"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": "<id:number>",
"identity_public_key": "YWxpY2UtaWRlbnRpdHktcHVibGljLWtleS1maXh0dXJl",
"role": "member",
"username": "alice"
}
},
"seq": "<seq:number>",
"type": "member_join"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"custom_status": "fixture custom status",
"status": "online",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "presence"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"status": "online",
"user": {
"avatar": null,
"id": "<id:number>",
"role": "member",
"username": "bob"
}
},
"seq": "<seq:number>",
"type": "member_join"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"custom_status": null,
"status": "online",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "presence"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
],
"b": [
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"last_seq": "<seq:number>",
"token": "<token:string>"
},
"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": "<id:number>",
"role": "member",
"status": "online",
"username": "bob"
}
},
"type": "auth_ok"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"channels": [
{
"can_send": true,
"category": "",
"id": "<id:number>",
"last_message_id": "<id:number>",
"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": "<id:number>",
"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": "<id:number>",
"identity_public_key": "YWxpY2UtaWRlbnRpdHktcHVibGljLWtleS1maXh0dXJl",
"role": "member",
"status": "online",
"username": "alice"
},
{
"avatar": null,
"custom_status": null,
"display_name": null,
"id": "<id:number>",
"role": "member",
"status": "online",
"username": "bob"
}
],
"motd": "Welcome!",
"roles": [
{
"color": "#E74C3C",
"id": "<id:number>",
"is_default": false,
"name": "Owner",
"permissions": 2147483647,
"position": 100
},
{
"color": "#F39C12",
"id": "<id:number>",
"is_default": false,
"name": "Admin",
"permissions": 1073741823,
"position": 80
},
{
"color": "#3498DB",
"id": "<id:number>",
"is_default": false,
"name": "Moderator",
"permissions": 3145727,
"position": 60
},
{
"color": null,
"id": "<id:number>",
"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": "<id:number>",
"role": "member",
"username": "bob"
}
},
"seq": "<seq:number>",
"type": "member_join"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"custom_status": null,
"status": "online",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "presence"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
]
}
}
+29
View File
@@ -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"
}
}
]
}
}
+33
View File
@@ -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"
}
}
]
}
}
@@ -0,0 +1,113 @@
{
"journey": "reaction-add-remove",
"connections": {
"a": [
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"emoji": "👍",
"message_id": "<id:number>"
},
"type": "reaction_add"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"action": "add",
"channel_id": 1,
"emoji": "👍",
"message_id": "<id:number>",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "reaction_update"
}
},
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"emoji": "👍",
"message_id": "<id:number>"
},
"type": "reaction_remove"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"action": "remove",
"channel_id": 1,
"emoji": "👍",
"message_id": "<id:number>",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"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": "<id:number>",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "reaction_update"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"action": "remove",
"channel_id": 1,
"emoji": "👍",
"message_id": "<id:number>",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "reaction_update"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
]
}
}
@@ -0,0 +1,178 @@
{
"journey": "resume-replay",
"connections": {
"a": [
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"active_channel_id": "<id:number>",
"last_seq": "<seq:number>",
"token": "<token:string>"
},
"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": "<id:number>",
"role": "member",
"status": "online",
"username": "alice"
}
},
"type": "auth_ok"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"custom_status": null,
"status": "offline",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "presence"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"attachments": [],
"channel_id": 1,
"content": "sent while away",
"id": "<id:number>",
"mentions": [],
"mentions_everyone": false,
"mentions_here": false,
"pinned": false,
"reactions": [],
"reply_to": null,
"timestamp": "<ts:string>",
"user": {
"avatar": null,
"id": "<id:number>",
"role": "member",
"username": "bob"
}
},
"seq": "<seq:number>",
"type": "chat_message"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"custom_status": "fixture custom status",
"status": "online",
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"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": "<id:number>"
},
"seq": "<seq:number>",
"type": "presence"
}
},
{
"dir": "c2s",
"frame": {
"id": "<id:string>",
"payload": {
"channel_id": 1,
"content": "sent while away"
},
"type": "chat_send"
}
},
{
"dir": "s2c",
"frame": {
"id": "<id:string>",
"payload": {
"message_id": "<id:number>",
"timestamp": "<ts:string>"
},
"type": "chat_send_ok"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"attachments": [],
"channel_id": 1,
"content": "sent while away",
"id": "<id:number>",
"mentions": [],
"mentions_everyone": false,
"mentions_here": false,
"pinned": false,
"reactions": [],
"reply_to": null,
"timestamp": "<ts:string>",
"user": {
"avatar": null,
"id": "<id:number>",
"role": "member",
"username": "bob"
}
},
"seq": "<seq:number>",
"type": "chat_message"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
]
}
}
+55
View File
@@ -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": "<id:number>",
"username": "alice"
},
"type": "typing"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
]
}
}
@@ -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": "<token:string>",
"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": "<id:number>",
"username": "bob"
},
"seq": "<seq:number>",
"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": "<id:number>"
},
"type": "voice_e2ee_announce"
}
},
{
"dir": "c2s",
"frame": {
"payload": {
"encrypted_key": "ZW5jcnlwdGVkLXJvb20ta2V5LWZpeHR1cmU=",
"iv": "aXYtZml4dHVyZS0xMg==",
"target_user_id": "<id:number>"
},
"type": "voice_e2ee_offer"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "voice_leave"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"channel_id": 2,
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"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": "<id:number>",
"username": "alice"
},
"seq": "<seq:number>",
"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": "<token:string>",
"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": "<id:number>",
"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": "<id:number>"
},
"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": "<id:number>",
"iv": "aXYtZml4dHVyZS0xMg=="
},
"type": "voice_e2ee_offer"
}
},
{
"dir": "s2c",
"frame": {
"payload": {
"channel_id": 2,
"user_id": "<id:number>"
},
"seq": "<seq:number>",
"type": "voice_leave"
}
},
{
"dir": "c2s",
"frame": {
"payload": {},
"type": "ping"
}
},
{
"dir": "s2c",
"frame": {
"type": "pong"
}
}
]
}
}
-2
View File
@@ -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" },