From 9c9b8be669d0ec64cbaffaf686724f9d9e03fbc9 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:23:06 +0200 Subject: [PATCH] feat(b2-2): protocol epoch and negotiation (slim) (#1438) * feat(b2-2): declare protocol_epoch in the schema and generate both constants protocol/schema.json gains protocol_epoch (1). genprotocol emits ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go constant to the schema so a stale regeneration fails the required check. * feat(b2-2): check the client's protocol epoch in the auth handshake The auth payload gains epoch (absent = 0). Outside [minClientEpoch, ProtocolEpoch] the server answers one auth_error with code protocol_epoch_unsupported, the client/server/min epochs, and a message naming which side to update, then closes 1008 like every other handshake failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep connecting; the epoch-1 fixtures are unchanged. * feat(b2-2): send the protocol epoch and offer the update on a refused connect ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended on purpose). On auth_error code protocol_epoch_unsupported with a newer server the dispatcher records the host in ui.store.updateRequiredHost and main.ts mounts the UpdateNotifier on the connect page, so a refused client gets the same Update Now banner it would have had on the main page. * feat(b2-2): withhold client releases newer than the server's protocol epoch The signed server-update manifest gains protocol_epoch (release.yml reads it from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the manifest and reads it; the client-update endpoint answers 204 when the release's epoch is newer than ws.ProtocolEpoch or the manifest does not verify. Releases without a manifest are epoch 0 and advertised as before. Docs: protocol.md Compatibility section, api.md, deployment.md, protocol README, CHANGELOG Unreleased. * docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it * ci: prove the protocol_epoch manifest read on every PR, not only at tag time * fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal Codex P1: on a first login or startup auto-login no overlay exists before auth_ok, so a refusal never re-rendered the connect page and the one-time read of updateRequiredHost missed it. The connect page now subscribes to it, and a later refusal replaces the banner. Codex P2: a refusal on reconnect went through the generic logout and deleted the stored credential although the token is still valid. clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it (the skip-auto-login flag is still set and, being sessionStorage, does not survive the relaunch the update triggers). --- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 9 +- CHANGELOG.md | 25 +++ Client/src/lib/dispatcher.ts | 14 +- Client/src/lib/protocolTypes.ts | 4 + Client/src/lib/types.ts | 11 ++ Client/src/lib/ws.ts | 2 + Client/src/main.ts | 44 ++++- Client/src/stores/auth.store.ts | 7 +- Client/src/stores/ui.store.ts | 11 ++ Client/tests/contract/ws-auth-frame.test.ts | 28 ++-- Client/tests/helpers/test-utils.ts | 1 + Client/tests/unit/channel-sidebar.test.ts | 1 + Client/tests/unit/dispatcher.test.ts | 49 +++++- Client/tests/unit/main.test.ts | 94 +++++++++++ Client/tests/unit/sidebar-area.test.ts | 1 + Client/tests/unit/sidebar-dm-helpers.test.ts | 1 + Client/tests/unit/sidebar-dm-section.test.ts | 1 + Client/tests/unit/ui.store.test.ts | 1 + Client/tests/unit/voice-disconnect.test.ts | 1 + Server/api/client_update.go | 11 ++ Server/api/client_update_epoch_test.go | 73 ++++++++ Server/cmd/genprotocol/main.go | 10 ++ Server/updater/release_epoch_test.go | 73 ++++++++ Server/updater/verify.go | 32 ++++ Server/ws/message_types.go | 4 + Server/ws/messages.go | 34 ++++ Server/ws/protocol_contract_test.go | 18 ++ Server/ws/protocol_epoch_test.go | 73 ++++++++ Server/ws/serve_auth.go | 8 + docs/api.md | 7 +- docs/deployment.md | 8 + .../b2-protocol-trust-compat-2026-08-28.md | 157 +++++++++--------- docs/protocol.md | 49 ++++++ protocol/README.md | 5 + protocol/schema.json | 1 + 36 files changed, 766 insertions(+), 108 deletions(-) create mode 100644 Server/api/client_update_epoch_test.go create mode 100644 Server/updater/release_epoch_test.go create mode 100644 Server/ws/protocol_epoch_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab6aa2f..57df43d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,6 +193,12 @@ jobs: - name: Ledger schema is valid run: node .superpowers/render-ledger.mjs --check + # release.yml writes protocol_epoch into the signed server-update + # manifest with exactly this command, and release.yml only runs at tag + # time — so the read is proven here, on every pull request. + - name: protocol_epoch is readable the way release.yml reads it + run: test "$(jq -e '.protocol_epoch' protocol/schema.json)" -ge 1 + # R-09 / RL-16. The release gate itself is only invoked for real at tag # time, which is the wrong place to find a bug in it — so its decision # logic is exercised here, on every pull request, against fixtures. Same diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c49d8fa..f01e8c24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -574,8 +574,13 @@ jobs: run: | WIN_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}') LINUX_HASH=$(sha256sum linux/chatserver-linux-amd64.tar.gz | awk '{print $1}') - printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}]}' \ - "$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" > windows/server-update-manifest.json + # protocol_epoch is read from the schema, never typed here, so the + # manifest cannot drift from the constants the binaries were built with. + # The server's client-update endpoint withholds any release whose epoch + # is newer than its own (Server/api/client_update.go). + EPOCH=$(jq -e '.protocol_epoch' protocol/schema.json) + printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}],"protocol_epoch":%s}' \ + "$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" "$EPOCH" > windows/server-update-manifest.json - name: Sign server update assets working-directory: Client diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b2c9f4..d2360396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,31 @@ ownership, dependency automation — gets **at most a short block at the end**, and only when it changes something a contributor or fork holder must do (a moved directory, a renamed module, a new required command). +## Unreleased + +User-visible: one change to how updates roll out. Not user-visible: the +protocol now carries a version number. + +### Login & connection + +- The client and server now agree on a protocol version ("epoch") when + connecting. This release is epoch 1; clients from v1.2.0-alpha.4 and earlier + still connect. +- A client too old for its server is told "update the client" on the connect + screen, with the usual Update Now button — instead of failing in confusing + ways. The saved login is kept, so the updated client signs back in by + itself. +- **Upgrade the server before the clients.** The server only offers client + releases that speak its own protocol epoch, so a protocol-changing release + reaches clients once the server runs it. Releases that do not change the + protocol are offered as before. + +### Repository + +- `protocol/schema.json` declares `protocol_epoch`; `npm run generate` emits it + as `ws.ProtocolEpoch` and `PROTOCOL_EPOCH`. Rules for bumping it: + `docs/protocol.md`, Compatibility. + ## v1.2.0-alpha.4 **62 bug fixes**, all user-visible, plus repository work that changes nothing an diff --git a/Client/src/lib/dispatcher.ts b/Client/src/lib/dispatcher.ts index c0556c9d..4569ec4f 100644 --- a/Client/src/lib/dispatcher.ts +++ b/Client/src/lib/dispatcher.ts @@ -5,7 +5,7 @@ import type { WsClient } from "./ws"; import { toConnectionStatus, setActiveChannelProvider } from "./ws"; import { authStore, setAuth, clearAuth, updateUser } from "@stores/auth.store"; -import { setTransientError, setConnectionStatus } from "@stores/ui.store"; +import { setTransientError, setConnectionStatus, setUpdateRequiredHost } from "@stores/ui.store"; import { setChannels, setRoles, @@ -81,7 +81,7 @@ import { ensureIdentityKeyPublished } from "@lib/identity"; import { markChannelRead } from "./read-state"; import { createLogger } from "./logger"; import { showToast } from "./toast"; -import { ServerMessageType as S } from "./protocolTypes"; +import { ServerMessageType as S, PROTOCOL_EPOCH } from "./protocolTypes"; // SidebarDmHelpers is page-level, but addDmToChannelsStore is the only // place the DM->channelsStore mirror row is synthesized (selectDmConversation // on open); the dm_channel_close fallback below needs the same synthesis for @@ -292,7 +292,15 @@ export function wireDispatcher( ws.on(S.AUTH_ERROR, (payload) => { log.error("Auth failed", { message: payload.message }); setTransientError(payload.message); - clearAuth(); + const epochRefusal = payload.code === "protocol_epoch_unsupported"; + // The server speaks a newer protocol than this build: hand the host to + // the connect page so it can offer the client update right there. + if (epochRefusal && (payload.server_epoch ?? 0) > PROTOCOL_EPOCH) { + setUpdateRequiredHost(api?.getConfig?.().host ?? null); + } + // A protocol refusal is not a bad token: say so, so main.ts keeps the + // stored credential for the relaunch after the update. + clearAuth(epochRefusal ? "protocol_epoch" : "user"); }), ); diff --git a/Client/src/lib/protocolTypes.ts b/Client/src/lib/protocolTypes.ts index e4741c1f..9f41e94f 100644 --- a/Client/src/lib/protocolTypes.ts +++ b/Client/src/lib/protocolTypes.ts @@ -7,6 +7,10 @@ // Usage: import { MessageType } from "@lib/protocolTypes"; // ws.send({ type: MessageType.CHAT_SEND, payload: { ... } }); +// The wire epoch this client speaks; sent in the auth frame and checked by +// the server. See docs/protocol.md, Compatibility. +export const PROTOCOL_EPOCH = 1; + // --------------------------------------------------------------------------- // Server → Client message types // --------------------------------------------------------------------------- diff --git a/Client/src/lib/types.ts b/Client/src/lib/types.ts index 3c79c579..caed8523 100644 --- a/Client/src/lib/types.ts +++ b/Client/src/lib/types.ts @@ -282,6 +282,15 @@ export interface AuthOkPayload { export interface AuthErrorPayload { readonly message: string; + /** + * Set only when the server refused this client's protocol epoch + * (`"protocol_epoch_unsupported"`); the epochs say which side is older. + * Absent on every other refusal. + */ + readonly code?: "protocol_epoch_unsupported"; + readonly client_epoch?: number; + readonly server_epoch?: number; + readonly min_epoch?: number; } export interface ReadyPayload { @@ -631,6 +640,8 @@ export interface ErrorPayload { export interface AuthPayload { readonly token: string; readonly last_seq?: number; + /** The wire epoch this client speaks — always `PROTOCOL_EPOCH`. */ + readonly epoch: number; /** * The channel this client had open when it disconnected, sent only on a * resume (`last_seq > 0`). diff --git a/Client/src/lib/ws.ts b/Client/src/lib/ws.ts index 81b01658..d9caaf1b 100644 --- a/Client/src/lib/ws.ts +++ b/Client/src/lib/ws.ts @@ -4,6 +4,7 @@ import type { ServerMessage, ClientMessage } from "./types"; import { createLogger } from "./logger"; +import { PROTOCOL_EPOCH } from "./protocolTypes"; const log = createLogger("ws"); @@ -448,6 +449,7 @@ export function createWsClient() { payload: { token: config.token, last_seq: lastSeq, + epoch: PROTOCOL_EPOCH, ...(activeChannelId !== null ? { active_channel_id: activeChannelId } : {}), }, }); diff --git a/Client/src/main.ts b/Client/src/main.ts index 04f581fc..2ec0fdd0 100644 --- a/Client/src/main.ts +++ b/Client/src/main.ts @@ -12,7 +12,7 @@ import { createApiClient } from "@lib/api"; import { createWsClient, normalizeHostForCertCompare } from "@lib/ws"; import { wireDispatcher, wireConnectionStatus } from "@lib/dispatcher"; import { authStore, clearAuth } from "@stores/auth.store"; -import { setTransientError } from "@stores/ui.store"; +import { setTransientError, uiStore, setUpdateRequiredHost } from "@stores/ui.store"; import { voiceStore, leaveVoiceChannel } from "@stores/voice.store"; import { createConnectPage } from "@pages/ConnectPage"; import { applyStoredAppearance } from "@lib/appearance"; @@ -20,6 +20,8 @@ import { restoreTheme } from "@lib/themes"; import { initPtt } from "@lib/ptt"; import { createNavigationGuard } from "@lib/navigation-guard"; import { createConnectedOverlay } from "@components/ConnectedOverlay"; +import { createUpdateNotifier } from "@components/UpdateNotifier"; +import type { MountableComponent } from "@lib/safe-render"; import type { ConnectedOverlayControl } from "@components/ConnectedOverlay"; import { createLogger, applyStoredLogLevel } from "@lib/logger"; import { initLogPersistence, flushLogs } from "@lib/logPersistence"; @@ -626,6 +628,27 @@ async function renderPage(pageId: "connect" | "main"): Promise { safeMount(connectPage, appEl!); + // A server refused this client's protocol epoch as too old: offer the + // update on the connect page itself. The main page's notifier never + // mounts on a refusal, so without this the user would have to fetch the + // installer by hand. Subscribed, not read once: on a first login or a + // startup auto-login this page is already mounted when the refusal + // arrives and nothing re-renders it (no overlay exists before auth_ok, so + // the isAuthenticated subscriber below does not navigate). + let updateNotifier: MountableComponent | null = null; + const offerUpdate = (host: string | null): void => { + if (!host) return; + setUpdateRequiredHost(null); + // A later refusal (another server tried from this same page) replaces + // the banner rather than being ignored. + updateNotifier?.destroy?.(); + const notifier = createUpdateNotifier({ serverUrl: `https://${host}` }); + notifier.mount(appEl!); + updateNotifier = notifier; + }; + const unsubUpdateRequired = uiStore.subscribeSelector((s) => s.updateRequiredHost, offerUpdate); + offerUpdate(uiStore.getState().updateRequiredHost); + // Periodic health check — re-run every 15s so offline servers update when they come back const healthCheckInterval = setInterval(() => { runHealthChecks(connectPage, getProfileList()); @@ -635,6 +658,8 @@ async function renderPage(pageId: "connect" | "main"): Promise { currentPage = { destroy() { clearInterval(healthCheckInterval); + unsubUpdateRequired(); + updateNotifier?.destroy?.(); connectPage.destroy?.(); }, }; @@ -805,12 +830,17 @@ authStore.subscribeSelector( // kicked us by shutting down: the token is still valid, and deleting // the credential would break auto-login every time the server restarts. const host = api.getConfig().host; - if (host && authStore.getState().logoutReason !== "server_shutdown") { - void deleteCredential(host); - // Same condition on purpose: whenever the credential is being removed, - // the connect page must not turn around and auto-login with it. A - // server_shutdown keeps the credential precisely so auto-login still - // works on restart, so it deliberately does not set this. + const reason = authStore.getState().logoutReason; + if (host && reason !== "server_shutdown") { + // A protocol-epoch refusal keeps the credential too: the token is + // still valid, and the update the connect page offers relaunches + // straight into auto-login with it (sessionStorage — and so the + // skip flag below — does not survive that relaunch). + if (reason !== "protocol_epoch") void deleteCredential(host); + // Whenever this session must not turn around and auto-login with the + // credential (removed, or just refused), say so. A server_shutdown + // keeps the credential precisely so auto-login still works on + // restart, so it deliberately does not set this. sessionStorage.setItem("owncord:skip-auto-login", "1"); } router.navigate("connect"); diff --git a/Client/src/stores/auth.store.ts b/Client/src/stores/auth.store.ts index 6911094c..d3ad644d 100644 --- a/Client/src/stores/auth.store.ts +++ b/Client/src/stores/auth.store.ts @@ -21,7 +21,12 @@ const log = createLogger("auth.store"); * server-initiated kick whose token is still valid — the logout wiring keeps * the saved credential in that case so auto-login works when the server * comes back. */ -export type LogoutReason = "user" | "server_shutdown"; +/** + * Why the session ended. "protocol_epoch": the server refused this client's + * wire epoch — the token is still valid, so main.ts keeps the stored + * credential and the update it offers relaunches into auto-login. + */ +export type LogoutReason = "user" | "server_shutdown" | "protocol_epoch"; export interface AuthState { readonly token: string | null; diff --git a/Client/src/stores/ui.store.ts b/Client/src/stores/ui.store.ts index 962e5506..9c84f053 100644 --- a/Client/src/stores/ui.store.ts +++ b/Client/src/stores/ui.store.ts @@ -14,6 +14,12 @@ export interface UiState { readonly connectionStatus: "connected" | "reconnecting" | "disconnected"; readonly transientError: string | null; readonly persistentError: string | null; + /** + * Host of a server that refused this client's protocol epoch as too old. + * main.ts consumes it when the connect page mounts, to offer the update + * there — the main page's own notifier never mounts on a refusal. + */ + readonly updateRequiredHost: string | null; readonly collapsedCategories: ReadonlySet; readonly sidebarMode: "channels" | "dms"; readonly activeDmUserId: number | null; @@ -28,6 +34,7 @@ const INITIAL_STATE: UiState = { connectionStatus: "disconnected", transientError: null, persistentError: null, + updateRequiredHost: null, collapsedCategories: new Set(), sidebarMode: "channels", activeDmUserId: null, @@ -108,6 +115,10 @@ export function setTransientError(msg: string | null): void { } /** Set a persistent error message that requires user action. */ +export function setUpdateRequiredHost(host: string | null): void { + uiStore.setState((prev) => ({ ...prev, updateRequiredHost: host })); +} + export function setPersistentError(msg: string | null): void { uiStore.setState((prev) => ({ ...prev, diff --git a/Client/tests/contract/ws-auth-frame.test.ts b/Client/tests/contract/ws-auth-frame.test.ts index dbb0ba6d..8fe2e01b 100644 --- a/Client/tests/contract/ws-auth-frame.test.ts +++ b/Client/tests/contract/ws-auth-frame.test.ts @@ -1,10 +1,10 @@ // 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. +// test freezes for the server. B2-2 added the `epoch` field (the wire epoch +// this client speaks, PROTOCOL_EPOCH from protocolTypes.ts); the key sets +// below include it deliberately. Any further field MUST fail here until it is +// added on purpose. 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 @@ -25,6 +25,7 @@ vi.mock("@tauri-apps/api/event", async () => ({ import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "../unit/helpers/ws-mocks"; import { createWsClient, setActiveChannelProvider } from "../../src/lib/ws"; +import { PROTOCOL_EPOCH } from "../../src/lib/protocolTypes"; /** Parses the most recently sent `auth` frame (envelope + payload) from ws_send. */ function getAuthFrame(): { type: string; payload: Record } { @@ -63,7 +64,7 @@ describe("contract: auth frame key set (epoch 1)", () => { vi.useRealTimers(); }); - it("fresh connect: envelope keys are exactly [type, payload, id], payload keys exactly [token, last_seq]", async () => { + it("fresh connect: envelope keys are exactly [type, payload, id], payload keys exactly [token, last_seq, epoch]", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); @@ -76,12 +77,14 @@ describe("contract: auth frame key set (epoch 1)", () => { // 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(Object.keys(frame.payload).sort()).toEqual(["epoch", "last_seq", "token"]); expect(frame.payload.token).toBe("t"); expect(frame.payload.last_seq).toBe(0); + expect(frame.payload.epoch).toBe(PROTOCOL_EPOCH); + expect(PROTOCOL_EPOCH).toBe(1); }); - it("resume with a registered active-channel provider: payload keys exactly [token, last_seq, active_channel_id]", async () => { + it("resume with a registered active-channel provider: payload keys exactly [token, last_seq, active_channel_id, epoch]", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); @@ -107,12 +110,17 @@ describe("contract: auth frame key set (epoch 1)", () => { emitTauriEvent("ws-state", "open"); const frame = getAuthFrame(); - expect(Object.keys(frame.payload).sort()).toEqual(["active_channel_id", "last_seq", "token"]); + expect(Object.keys(frame.payload).sort()).toEqual([ + "active_channel_id", + "epoch", + "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 () => { + it("resume without a provider registered: payload keys stay exactly [token, last_seq, epoch]", async () => { client.connect({ host: "localhost:8443", token: "t" }); await vi.advanceTimersByTimeAsync(10); emitTauriEvent("ws-state", "open"); @@ -137,7 +145,7 @@ describe("contract: auth frame key set (epoch 1)", () => { emitTauriEvent("ws-state", "open"); const frame = getAuthFrame(); - expect(Object.keys(frame.payload).sort()).toEqual(["last_seq", "token"]); + expect(Object.keys(frame.payload).sort()).toEqual(["epoch", "last_seq", "token"]); expect(frame.payload.last_seq).toBe(3); }); }); diff --git a/Client/tests/helpers/test-utils.ts b/Client/tests/helpers/test-utils.ts index 01f31fc5..e9cbdd2d 100644 --- a/Client/tests/helpers/test-utils.ts +++ b/Client/tests/helpers/test-utils.ts @@ -72,6 +72,7 @@ const UI_INITIAL: UiState = { connectionStatus: "disconnected", transientError: null, persistentError: null, + updateRequiredHost: null, collapsedCategories: new Set(), sidebarMode: "channels", activeDmUserId: null, diff --git a/Client/tests/unit/channel-sidebar.test.ts b/Client/tests/unit/channel-sidebar.test.ts index f56dbc0d..a7cf9440 100644 --- a/Client/tests/unit/channel-sidebar.test.ts +++ b/Client/tests/unit/channel-sidebar.test.ts @@ -71,6 +71,7 @@ function resetStores(): void { connectionStatus: "connected" as const, transientError: null, persistentError: null, + updateRequiredHost: null, collapsedCategories: new Set(), sidebarMode: "channels" as const, activeDmUserId: null, diff --git a/Client/tests/unit/dispatcher.test.ts b/Client/tests/unit/dispatcher.test.ts index eaa2db14..a7f48179 100644 --- a/Client/tests/unit/dispatcher.test.ts +++ b/Client/tests/unit/dispatcher.test.ts @@ -28,7 +28,8 @@ import { listCustomEmoji, resolveEmoji, } from "../../src/stores/emoji.store"; -import { uiStore } from "../../src/stores/ui.store"; +import { uiStore, setUpdateRequiredHost } from "../../src/stores/ui.store"; +import { PROTOCOL_EPOCH } from "../../src/lib/protocolTypes"; import { clearReactionUsersCache, getCachedReactionUsers, @@ -284,6 +285,52 @@ describe("WS Dispatcher", () => { expect(uiStore.getState().transientError).toBe("Invalid token"); }); + it("marks the server host as needing a client update when auth_error refuses this client's epoch as too old", () => { + cleanup(); + setUpdateRequiredHost(null); + const getConfig = vi.fn(() => ({ host: "chat.example:8443", token: "t" })); + cleanup = wireDispatcher(mock.ws, { listBlocks: vi.fn().mockResolvedValue([]), getConfig }); + + mock.dispatch("auth_error", { + message: "update the client", + code: "protocol_epoch_unsupported", + client_epoch: PROTOCOL_EPOCH, + server_epoch: PROTOCOL_EPOCH + 1, + min_epoch: PROTOCOL_EPOCH + 1, + }); + + expect(uiStore.getState().updateRequiredHost).toBe("chat.example:8443"); + expect(uiStore.getState().transientError).toBe("update the client"); + expect(authStore.getState().isAuthenticated).toBe(false); + // The token is still valid — main.ts keeps the stored credential on this + // reason so the update relaunches straight into auto-login (Codex P2). + expect(authStore.getState().logoutReason).toBe("protocol_epoch"); + }); + + it("does not offer a client update when the SERVER is the older side, or on an ordinary auth_error", () => { + cleanup(); + setUpdateRequiredHost(null); + const getConfig = vi.fn(() => ({ host: "chat.example:8443", token: "t" })); + cleanup = wireDispatcher(mock.ws, { listBlocks: vi.fn().mockResolvedValue([]), getConfig }); + + mock.dispatch("auth_error", { + message: "update the server", + code: "protocol_epoch_unsupported", + client_epoch: PROTOCOL_EPOCH, + server_epoch: PROTOCOL_EPOCH - 1, + min_epoch: PROTOCOL_EPOCH - 1, + }); + expect(uiStore.getState().updateRequiredHost).toBeNull(); + + // Server older than the client: still a protocol refusal, still a valid + // token — the credential must survive this one too. + expect(authStore.getState().logoutReason).toBe("protocol_epoch"); + + mock.dispatch("auth_error", { message: "Invalid token" }); + expect(uiStore.getState().updateRequiredHost).toBeNull(); + expect(authStore.getState().logoutReason).toBe("user"); + }); + it("wires ready to channels, members, and voice stores", () => { mock.dispatch("ready", { channels: [ diff --git a/Client/tests/unit/main.test.ts b/Client/tests/unit/main.test.ts index e18833ca..02151fb2 100644 --- a/Client/tests/unit/main.test.ts +++ b/Client/tests/unit/main.test.ts @@ -81,6 +81,14 @@ vi.mock("@lib/profiles", () => ({ // `api.getConfig().host` read (main.ts:776) after a login sets it via // `api.setConfig({ host })` (main.ts:515). const mockLogin = vi.fn(); +// UpdateNotifier (mounted on the connect page after a protocol-epoch refusal) +// calls checkForUpdate; stub the Tauri-backed updater so the test observes the +// call instead of an invoke() into nothing. +const mockCheckForUpdate = vi.fn(); +vi.mock("@lib/updater", () => ({ + checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args), + downloadAndInstallUpdate: vi.fn(), +})); const mockApiState = { host: "" }; vi.mock("@lib/api", () => ({ createApiClient: vi.fn(() => ({ @@ -151,6 +159,8 @@ vi.mock("@lib/dispatcher", async () => { import { mockInvoke, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks"; import { clearAuth } from "@stores/auth.store"; +import { deleteCredential } from "@lib/credentials"; +import { uiStore, setUpdateRequiredHost } from "@stores/ui.store"; import { loadUserStatus, loadUserStatusOrigin } from "@lib/userStatus"; import { createMainPage } from "@pages/MainPage"; import { setActivePresenceSender, type PresenceSender } from "@lib/presence"; @@ -379,3 +389,87 @@ describe("main.ts connect-page skip-auto-login flag (OC-0028)", () => { expect(sessionStorage.getItem("owncord:skip-auto-login")).toBeNull(); }); }); + +describe("main.ts connect page after a protocol-epoch refusal (B2-2)", () => { + it("mounts the update notifier on the connect page so a refused client can update in place", async () => { + await loginAndReachAuthOk("server-a.example:8443", "alex", { + user: { id: 1, username: "alex", avatar: null, role: "member" }, + server_name: "Server A", + motd: "", + }); + emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} })); + await vi.advanceTimersByTimeAsync(800); + + // The real dispatcher's auth_error handler records the host when the + // server says this client's epoch is too old (dispatcher.test.ts covers + // that); the dispatcher is stubbed here, so set what it would have set, + // then end the session the way auth_error does. + mockCheckForUpdate.mockResolvedValue({ available: false, version: null, body: null }); + setUpdateRequiredHost("server-a.example:8443"); + clearAuth(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // The notifier checks 3 s after mount (UpdateNotifier.ts mount()). + await vi.advanceTimersByTimeAsync(3000); + expect(mockCheckForUpdate).toHaveBeenCalledWith("https://server-a.example:8443"); + // Consumed on mount: the next connect page must not re-check. + expect(uiStore.getState().updateRequiredHost).toBeNull(); + }); + + it("offers the update when the refusal lands on an already-mounted connect page (first login / startup auto-login)", async () => { + // No session, no overlay: the connect page rendered at startup is the + // one the refusal arrives on, and nothing re-renders it (Codex P1). The + // dispatcher is stubbed here; set what its auth_error handler sets. + mockCheckForUpdate.mockClear(); + mockCheckForUpdate.mockResolvedValue({ available: false, version: null, body: null }); + setUpdateRequiredHost("server-c.example:8443"); + await Promise.resolve(); + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(3000); + expect(mockCheckForUpdate).toHaveBeenCalledWith("https://server-c.example:8443"); + expect(uiStore.getState().updateRequiredHost).toBeNull(); + }); + + it("keeps the stored credential on a protocol-epoch refusal, unlike an ordinary auth_error", async () => { + await loginAndReachAuthOk("server-d.example:8443", "alex", { + user: { id: 1, username: "alex", avatar: null, role: "member" }, + server_name: "Server D", + motd: "", + }); + emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} })); + await vi.advanceTimersByTimeAsync(800); + + vi.mocked(deleteCredential).mockClear(); + // What the dispatcher does on protocol_epoch_unsupported (Codex P2). + clearAuth("protocol_epoch"); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // The token is still valid: the credential stays so the update can + // relaunch into auto-login. (The skip-auto-login flag is set on the same + // path, but the connect page consumes it on mount, so it cannot be read + // back here — the quick-switch test above covers that consumption.) + expect(deleteCredential).not.toHaveBeenCalled(); + + // Contrast: the same logout for an ordinary reason removes it. + await loginAndReachAuthOk("server-d.example:8443", "alex", { + user: { id: 1, username: "alex", avatar: null, role: "member" }, + server_name: "Server D", + motd: "", + }); + emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} })); + await vi.advanceTimersByTimeAsync(800); + clearAuth("user"); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(deleteCredential).toHaveBeenCalledWith("server-d.example:8443"); + }); +}); diff --git a/Client/tests/unit/sidebar-area.test.ts b/Client/tests/unit/sidebar-area.test.ts index b3546a30..a83a357f 100644 --- a/Client/tests/unit/sidebar-area.test.ts +++ b/Client/tests/unit/sidebar-area.test.ts @@ -204,6 +204,7 @@ function resetStores(): void { connectionStatus: "disconnected" as const, transientError: null, persistentError: null, + updateRequiredHost: null, collapsedCategories: new Set(), sidebarMode: "channels" as const, activeDmUserId: null, diff --git a/Client/tests/unit/sidebar-dm-helpers.test.ts b/Client/tests/unit/sidebar-dm-helpers.test.ts index fbe71b64..03454892 100644 --- a/Client/tests/unit/sidebar-dm-helpers.test.ts +++ b/Client/tests/unit/sidebar-dm-helpers.test.ts @@ -36,6 +36,7 @@ function resetStores(): void { connectionStatus: "disconnected" as const, transientError: null, persistentError: null, + updateRequiredHost: null, collapsedCategories: new Set(), sidebarMode: "channels" as const, activeDmUserId: null, diff --git a/Client/tests/unit/sidebar-dm-section.test.ts b/Client/tests/unit/sidebar-dm-section.test.ts index 489f24d8..4e105384 100644 --- a/Client/tests/unit/sidebar-dm-section.test.ts +++ b/Client/tests/unit/sidebar-dm-section.test.ts @@ -23,6 +23,7 @@ function resetStores(): void { connectionStatus: "disconnected" as const, transientError: null, persistentError: null, + updateRequiredHost: null, collapsedCategories: new Set(), sidebarMode: "channels" as const, activeDmUserId: null, diff --git a/Client/tests/unit/ui.store.test.ts b/Client/tests/unit/ui.store.test.ts index 018903a9..67ba040a 100644 --- a/Client/tests/unit/ui.store.test.ts +++ b/Client/tests/unit/ui.store.test.ts @@ -28,6 +28,7 @@ function resetStore(): void { connectionStatus: "disconnected" as const, transientError: null, persistentError: null, + updateRequiredHost: null, collapsedCategories: new Set(), sidebarMode: "channels" as const, activeDmUserId: null, diff --git a/Client/tests/unit/voice-disconnect.test.ts b/Client/tests/unit/voice-disconnect.test.ts index 2f2597f3..02dcc4b1 100644 --- a/Client/tests/unit/voice-disconnect.test.ts +++ b/Client/tests/unit/voice-disconnect.test.ts @@ -57,6 +57,7 @@ function resetStores(): void { connectionStatus: "disconnected" as const, transientError: null, persistentError: null, + updateRequiredHost: null, collapsedCategories: new Set(), sidebarMode: "channels" as const, activeDmUserId: null, diff --git a/Server/api/client_update.go b/Server/api/client_update.go index bc15ae01..6d67a9e2 100644 --- a/Server/api/client_update.go +++ b/Server/api/client_update.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/J3vb/OwnCord/Server/updater" + "github.com/J3vb/OwnCord/Server/ws" "github.com/go-chi/chi/v5" "golang.org/x/mod/semver" ) @@ -58,6 +59,16 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc { return } + // Server first, clients second: never advertise a client that speaks + // a newer wire epoch than this server — it would auto-update straight + // into a refused handshake. The epoch comes from the release's signed + // manifest; a manifest that does not verify is withheld the same way. + epoch, err := u.ReleaseProtocolEpoch(r.Context(), info) + if err != nil || epoch > ws.ProtocolEpoch { + w.WriteHeader(http.StatusNoContent) + return + } + // Find the updater artifact and its signature for the requested // target ("{os}-{arch}-{installer}", e.g. "windows-x86_64-nsis"). // Targets without a published updater artifact get 204 — never a diff --git a/Server/api/client_update_epoch_test.go b/Server/api/client_update_epoch_test.go new file mode 100644 index 00000000..29776eee --- /dev/null +++ b/Server/api/client_update_epoch_test.go @@ -0,0 +1,73 @@ +package api_test + +// client_update_epoch_test.go — the client-update endpoint never advertises a +// release whose signed manifest declares a protocol epoch newer than this +// server's (B2-2): a client that auto-updated onto it would be refused at the +// next handshake. A manifest that does not verify is treated the same way — +// fail closed, 204 — since its epoch cannot be trusted. A release with no +// manifest at all predates the epoch and is advertised as before +// (client_update_test.go covers that path throughout). + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/J3vb/OwnCord/Server/updater" +) + +// fakeGitHubReleaseWithManifest is fakeGitHubRelease plus a server-update +// manifest and signature, served with the given bytes. +func fakeGitHubReleaseWithManifest(t *testing.T, tag string, manifest, sig []byte) *httptest.Server { + t.Helper() + var srv *httptest.Server + mux := http.NewServeMux() + assetNames := []string{ + "OwnCord_1.0.0_x64-setup.nsis.zip", + "OwnCord_1.0.0_x64-setup.nsis.zip.sig", + "server-update-manifest.json", + "server-update-manifest.json.sig", + } + mux.HandleFunc("/repos/test/repo/releases/latest", func(w http.ResponseWriter, _ *http.Request) { + assets := make([]map[string]any, 0, len(assetNames)) + for _, name := range assetNames { + assets = append(assets, map[string]any{"name": name, "browser_download_url": srv.URL + "/download/" + name}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": tag, "body": "notes", "html_url": "x", "assets": assets}) + }) + mux.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "server-update-manifest.json"): + _, _ = w.Write(manifest) + case strings.HasSuffix(r.URL.Path, "server-update-manifest.json.sig"): + _, _ = w.Write(sig) + case strings.HasSuffix(r.URL.Path, ".sig"): + _, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ=")) + default: + http.NotFound(w, r) + } + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestClientUpdate_UnverifiableManifestIsNotAdvertised(t *testing.T) { + // The manifest claims epoch 1 (compatible) but its signature is garbage: + // the claim is untrusted, so the release is withheld. + manifest := []byte(`{"version":"v2.0.0","asset":"chatserver.exe","sha256":"00","protocol_epoch":1}`) + srv := fakeGitHubReleaseWithManifest(t, "v2.0.0", manifest, []byte("not a signature")) + u := updater.NewUpdater("1.0.0", "", "test", "repo") + u.SetBaseURL(srv.URL) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil) + rr := httptest.NewRecorder() + buildClientUpdateRouter(u).ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204 (unverifiable manifest must not be advertised); body: %s", rr.Code, rr.Body.String()) + } +} diff --git a/Server/cmd/genprotocol/main.go b/Server/cmd/genprotocol/main.go index a1f2a7e5..b23c00b8 100644 --- a/Server/cmd/genprotocol/main.go +++ b/Server/cmd/genprotocol/main.go @@ -33,6 +33,7 @@ type message struct { type schema struct { Comment string `json:"$comment"` Version int `json:"version"` + ProtocolEpoch int `json:"protocol_epoch"` ClientToServer []message `json:"client_to_server"` ServerToClient []message `json:"server_to_client"` } @@ -74,6 +75,9 @@ func main() { // validate rejects duplicate identifiers and empty fields early so a bad // schema edit fails the generator instead of producing broken output. func validate(s schema) error { + if s.ProtocolEpoch < 1 { + return fmt.Errorf("protocol_epoch must be >= 1, got %d", s.ProtocolEpoch) + } goNames := map[string]bool{} for _, list := range [][]message{s.ClientToServer, s.ServerToClient} { tsNames := map[string]bool{} @@ -105,6 +109,9 @@ func renderGo(s schema) ([]byte, error) { "// both Server (Go) and Client (TypeScript). Edit protocol/schema.json\n" + "// and run `make protocol-generate` (see Server/Makefile).")) b.WriteString("\npackage ws\n\n") + b.WriteString("// ProtocolEpoch is the wire epoch this server speaks. The auth handshake\n") + b.WriteString("// negotiates on it (serve_auth.go); see docs/protocol.md, Compatibility.\n") + fmt.Fprintf(&b, "const ProtocolEpoch = %d\n\n", s.ProtocolEpoch) writeBlock := func(title string, msgs []message) { b.WriteString("// " + title + "\nconst (\n") @@ -152,6 +159,9 @@ func renderTS(s schema) string { b.WriteString("export type " + name + "Value = (typeof " + name + ")[keyof typeof " + name + "];\n") } + b.WriteString("\n// The wire epoch this client speaks; sent in the auth frame and checked by\n") + b.WriteString("// the server. See docs/protocol.md, Compatibility.\n") + fmt.Fprintf(&b, "export const PROTOCOL_EPOCH = %d;\n", s.ProtocolEpoch) writeBlock("Server → Client message types", "ServerMessageType", s.ServerToClient) writeBlock("Client → Server message types", "ClientMessageType", s.ClientToServer) diff --git a/Server/updater/release_epoch_test.go b/Server/updater/release_epoch_test.go new file mode 100644 index 00000000..d97beb5d --- /dev/null +++ b/Server/updater/release_epoch_test.go @@ -0,0 +1,73 @@ +package updater + +// release_epoch_test.go — ReleaseProtocolEpoch reads the protocol epoch a +// release's signed server-update manifest declares (B2-2). The client-update +// endpoint uses it to hold back a client release that speaks a newer wire +// than this server: the server upgrades first, the clients follow. + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// manifestServer serves the manifest and its signature at the URLs the +// UpdateInfo under test points at. +func manifestServer(t *testing.T, manifest, sig []byte) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/m.json", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(manifest) }) + mux.HandleFunc("/m.json.sig", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(sig) }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestReleaseProtocolEpoch(t *testing.T) { + u, key := newSignedTestUpdater(t, "", "1.0.0") + + t.Run("signed manifest declares the epoch", func(t *testing.T) { + manifest := []byte(`{"version":"v2.0.0","asset":"chatserver.exe","sha256":"` + testHash("exe") + `","protocol_epoch":2}`) + srv := manifestServer(t, manifest, signTestAsset(t, key, manifest)) + info := UpdateInfo{Latest: "v2.0.0", ManifestURL: srv.URL + "/m.json", ManifestSignatureURL: srv.URL + "/m.json.sig"} + + got, err := u.ReleaseProtocolEpoch(context.Background(), info) + if err != nil { + t.Fatalf("ReleaseProtocolEpoch: %v", err) + } + if got != 2 { + t.Fatalf("epoch = %d, want 2", got) + } + }) + + t.Run("manifest without the field is epoch 0", func(t *testing.T) { + manifest := []byte(`{"version":"v1.2.0","asset":"chatserver.exe","sha256":"` + testHash("exe") + `"}`) + srv := manifestServer(t, manifest, signTestAsset(t, key, manifest)) + info := UpdateInfo{Latest: "v1.2.0", ManifestURL: srv.URL + "/m.json", ManifestSignatureURL: srv.URL + "/m.json.sig"} + + got, err := u.ReleaseProtocolEpoch(context.Background(), info) + if err != nil || got != 0 { + t.Fatalf("epoch, err = %d, %v; want 0, nil", got, err) + } + }) + + t.Run("release without a manifest is epoch 0", func(t *testing.T) { + got, err := u.ReleaseProtocolEpoch(context.Background(), UpdateInfo{Latest: "v1.0.0"}) + if err != nil || got != 0 { + t.Fatalf("epoch, err = %d, %v; want 0, nil", got, err) + } + }) + + t.Run("tampered manifest is an error", func(t *testing.T) { + manifest := []byte(`{"version":"v2.0.0","asset":"chatserver.exe","sha256":"` + testHash("exe") + `","protocol_epoch":1}`) + sig := signTestAsset(t, key, manifest) + tampered := []byte(`{"version":"v2.0.0","asset":"chatserver.exe","sha256":"` + testHash("exe") + `","protocol_epoch":0}`) + srv := manifestServer(t, tampered, sig) + info := UpdateInfo{Latest: "v2.0.0", ManifestURL: srv.URL + "/m.json", ManifestSignatureURL: srv.URL + "/m.json.sig"} + + if _, err := u.ReleaseProtocolEpoch(context.Background(), info); err == nil { + t.Fatal("ReleaseProtocolEpoch accepted a manifest whose signature does not match") + } + }) +} diff --git a/Server/updater/verify.go b/Server/updater/verify.go index 621ecbb9..d1504769 100644 --- a/Server/updater/verify.go +++ b/Server/updater/verify.go @@ -2,6 +2,7 @@ package updater import ( "bytes" + "context" "crypto/sha256" _ "embed" "encoding/base64" @@ -35,6 +36,10 @@ type releaseManifest struct { SHA256 string `json:"sha256"` // Assets binds every server artifact the release ships (one per OS). Assets []releaseManifestAsset `json:"assets,omitempty"` + // ProtocolEpoch is the wire epoch the release's client speaks + // (protocol/schema.json protocol_epoch). Absent on releases up to + // v1.2.0-alpha.4, which is epoch 0. + ProtocolEpoch int `json:"protocol_epoch,omitempty"` } // releaseManifestAsset is one artifact binding in a multi-OS release manifest. @@ -300,3 +305,30 @@ func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error } return "", fmt.Errorf("file %q not found in checksum data", filename) } + +// ReleaseProtocolEpoch returns the protocol epoch a release's signed +// server-update manifest declares. A release with no manifest predates the +// epoch and is 0; a manifest whose signature does not verify is an error, +// never a guess. Both fetches go through the text-asset cache, so the +// unauthenticated client-update endpoint costs no outbound request per call. +func (u *Updater) ReleaseProtocolEpoch(ctx context.Context, info UpdateInfo) (int, error) { + if info.ManifestURL == "" || info.ManifestSignatureURL == "" { + return 0, nil + } + manifestText, err := u.FetchTextAssetCached(ctx, info.ManifestURL) + if err != nil { + return 0, fmt.Errorf("fetching release manifest: %w", err) + } + sigText, err := u.FetchTextAssetCached(ctx, info.ManifestSignatureURL) + if err != nil { + return 0, fmt.Errorf("fetching release manifest signature: %w", err) + } + if err := u.verifySignatureReader(strings.NewReader(manifestText), []byte(sigText), manifestAsset); err != nil { + return 0, fmt.Errorf("verifying release manifest signature: %w", err) + } + var manifest releaseManifest + if err := json.Unmarshal([]byte(manifestText), &manifest); err != nil { + return 0, fmt.Errorf("parsing release manifest: %w", err) + } + return manifest.ProtocolEpoch, nil +} diff --git a/Server/ws/message_types.go b/Server/ws/message_types.go index cd83ee45..446752bd 100644 --- a/Server/ws/message_types.go +++ b/Server/ws/message_types.go @@ -6,6 +6,10 @@ package ws +// ProtocolEpoch is the wire epoch this server speaks. The auth handshake +// negotiates on it (serve_auth.go); see docs/protocol.md, Compatibility. +const ProtocolEpoch = 1 + // Client → Server message types (received by handlers). const ( MsgTypeAuth = "auth" diff --git a/Server/ws/messages.go b/Server/ws/messages.go index af1cc2cb..6270e271 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -376,6 +376,40 @@ func buildAuthError(message string) []byte { }) } +// minClientEpoch is the oldest wire epoch the auth handshake still accepts. +// It is 0 for epoch 1 only, because clients up to v1.2.0-alpha.4 send no +// epoch at all and must keep connecting. +// ponytail: one accepted epoch by policy — set this to ProtocolEpoch on the +// next bump; widen to ProtocolEpoch-1 only if a compatibility window is ever +// actually wanted. +const minClientEpoch = 0 + +// ErrCodeProtocolEpoch is the auth_error code for a client whose wire epoch +// this server does not speak. +const ErrCodeProtocolEpoch = "protocol_epoch_unsupported" + +// buildProtocolEpochError is the auth_error for an epoch outside +// [minClientEpoch, ProtocolEpoch]. The message names which side to update; +// the numbers let a client decide for itself. +func buildProtocolEpochError(clientEpoch int) []byte { + message := fmt.Sprintf("this client speaks protocol epoch %d but the server needs %d; update the client", + clientEpoch, ProtocolEpoch) + if clientEpoch > ProtocolEpoch { + message = fmt.Sprintf("this client speaks protocol epoch %d but the server only speaks %d; update the server", + clientEpoch, ProtocolEpoch) + } + return buildJSON(map[string]any{ + "type": MsgTypeAuthError, + "payload": map[string]any{ + "message": message, + "code": ErrCodeProtocolEpoch, + "client_epoch": clientEpoch, + "server_epoch": ProtocolEpoch, + "min_epoch": minClientEpoch, + }, + }) +} + // --------------------------------------------------------------------------- // Typed message builders. // --------------------------------------------------------------------------- diff --git a/Server/ws/protocol_contract_test.go b/Server/ws/protocol_contract_test.go index 77fce256..26097414 100644 --- a/Server/ws/protocol_contract_test.go +++ b/Server/ws/protocol_contract_test.go @@ -36,6 +36,8 @@ import ( "strconv" "strings" "testing" + + "github.com/J3vb/OwnCord/Server/ws" ) var knownUndocumentedConstants = map[string]string{} @@ -50,6 +52,7 @@ type protocolSchemaEntry struct { type protocolSchema struct { Version int `json:"version"` + ProtocolEpoch int `json:"protocol_epoch"` ClientToServer []protocolSchemaEntry `json:"client_to_server"` ServerToClient []protocolSchemaEntry `json:"server_to_client"` } @@ -223,3 +226,18 @@ func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) { } } } + +// TestProtocolEpochMatchesSchema pins the generated ws.ProtocolEpoch to the +// protocol_epoch the schema declares. The epoch is the one number the auth +// handshake negotiates on (serve_auth.go); a stale regeneration here would +// let server and client disagree about which epoch they speak. +func TestProtocolEpochMatchesSchema(t *testing.T) { + schema := loadProtocolSchema(t) + if schema.ProtocolEpoch < 1 { + t.Fatalf("schema protocol_epoch = %d, want >= 1", schema.ProtocolEpoch) + } + if ws.ProtocolEpoch != schema.ProtocolEpoch { + t.Fatalf("ws.ProtocolEpoch = %d, schema protocol_epoch = %d — run `make protocol-generate`", + ws.ProtocolEpoch, schema.ProtocolEpoch) + } +} diff --git a/Server/ws/protocol_epoch_test.go b/Server/ws/protocol_epoch_test.go new file mode 100644 index 00000000..b6ef9efd --- /dev/null +++ b/Server/ws/protocol_epoch_test.go @@ -0,0 +1,73 @@ +package ws_test + +// protocol_epoch_test.go — the auth handshake's epoch check (B2-2). +// +// The server accepts an auth frame whose `epoch` lies in +// [minClientEpoch, ProtocolEpoch]; absent means 0, which this first epoch +// still accepts so v1.2.0-alpha.4 clients (no epoch at all) keep connecting. +// Anything else gets one auth_error carrying code +// "protocol_epoch_unsupported" plus the numbers a client needs to say which +// side is out of date, and then the same 1008 close every other handshake +// failure gets. Reuses the epoch-1 rig so the table drives a real socket. + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/J3vb/OwnCord/Server/ws" +) + +func TestAuth_ProtocolEpoch(t *testing.T) { + cases := []struct { + name string + epoch any // nil = field absent + accept bool + older bool // on reject: is the client the older side? + }{ + {name: "absent", epoch: nil, accept: true}, + {name: "zero", epoch: 0, accept: true}, + {name: "current", epoch: ws.ProtocolEpoch, accept: true}, + {name: "newer", epoch: ws.ProtocolEpoch + 1, accept: false, older: false}, + {name: "negative", epoch: -1, accept: false, older: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := newEpochRig(t, "protocol-epoch-"+tc.name) + _, tok := r.seedUser(t, "alice") + c := r.dial(t, "alice") + + payload := map[string]any{"token": tok, "last_seq": 0} + if tc.epoch != nil { + payload["epoch"] = tc.epoch + } + c.send(map[string]any{"type": "auth", "payload": payload}) + + if tc.accept { + c.expect("auth_ok") + return + } + + frame := c.expect("auth_error") + p, _ := frame["payload"].(map[string]any) + if got := p["code"]; got != "protocol_epoch_unsupported" { + t.Fatalf("code = %v, want protocol_epoch_unsupported: %v", got, p) + } + if got := p["server_epoch"]; got != json.Number("1") { + t.Fatalf("server_epoch = %v (%T), want 1", got, got) + } + if got := p["min_epoch"]; got != json.Number("0") { + t.Fatalf("min_epoch = %v, want 0", got) + } + msg, _ := p["message"].(string) + want := "update the server" + if tc.older { + want = "update the client" + } + if !strings.Contains(msg, want) { + t.Fatalf("message = %q, want it to say %q", msg, want) + } + c.expectClosed() + }) + } +} diff --git a/Server/ws/serve_auth.go b/Server/ws/serve_auth.go index 58652a93..64bb2dba 100644 --- a/Server/ws/serve_auth.go +++ b/Server/ws/serve_auth.go @@ -50,11 +50,19 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db // the handshake instead of leaving it unsubscribed until the // post-auth_ok channel_focus round trip lands. ActiveChannelID int64 `json:"active_channel_id"` + // Epoch is the wire epoch the client speaks (docs/protocol.md, + // Compatibility). Absent means 0: clients up to v1.2.0-alpha.4 predate + // the field. + Epoch int `json:"epoch"` } if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("missing token")) return nil, "", resumeHint{}, fmt.Errorf("auth: missing token") } + if p.Epoch < minClientEpoch || p.Epoch > ProtocolEpoch { + _ = conn.Write(ctx, websocket.MessageText, buildProtocolEpochError(p.Epoch)) + return nil, "", resumeHint{}, fmt.Errorf("auth: protocol epoch %d outside [%d, %d]", p.Epoch, minClientEpoch, ProtocolEpoch) + } hash := auth.HashToken(p.Token) sess, err := database.GetSessionByTokenHash(ctx, hash) diff --git a/docs/api.md b/docs/api.md index 61b4b61e..bfd1d788 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2583,7 +2583,12 @@ Tauri-compatible update endpoint. The desktop client checks this to see if a new #### Response 204 No Content -Client is already up-to-date, or no client build is published for `target`. +Client is already up-to-date, no client build is published for `target`, or +the newest release speaks a **newer protocol epoch than this server** (read +from the release's signed server-update manifest, `protocol_epoch`; a manifest +that fails signature verification is withheld the same way). The server +upgrades first, then its clients are offered the matching release — see +`docs/protocol.md`, Compatibility. --- diff --git a/docs/deployment.md b/docs/deployment.md index addd8a66..b53a7ec7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -99,6 +99,14 @@ docker compose up -d The named volume is preserved — no data loss. +**Upgrade the server before the clients.** Desktop clients fetch updates from +the server they connect to, and the server only offers releases whose protocol +epoch it can speak itself (`docs/protocol.md`, Compatibility). A release that +changes the wire protocol therefore reaches your users' clients only once the +server runs it; releases that do not change the protocol reach them regardless. +A client that is already too old for the server sees "update the client" on +its connect screen, with the usual Update Now button. + Pulling the image is the **only** upgrade path in Docker: the admin panel's in-place "Apply Update & Restart" is refused in container deployments (503 `CONTAINER_DEPLOYMENT`), because the running binary is image content — a 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 c2a5566d..60eca8df 100644 --- a/docs/plans/b2-protocol-trust-compat-2026-08-28.md +++ b/docs/plans/b2-protocol-trust-compat-2026-08-28.md @@ -5,7 +5,7 @@ `v1.2.0-alpha.4` — claims verified at `64d2e108`; the branch was rebased onto `dd7ed091` (#1432) before merge **Status:** in progress — entry gate 1 of 3 met at draft time (see below); B2-0, -B2-1 and B2-8 landed 2026-08-28 (evidence in their sections); B2-2 is next. +B2-1 and B2-8 landed 2026-08-28, B2-2 (with B2-3 and B2-4 folded in) on 2026-08-29 (evidence in their sections); B2-5 is next. Update this line, not only the step table, when a step lands. Primary inputs: @@ -38,9 +38,9 @@ one to one and a half weeks with agents working steps in parallel. | -------- | ----------------------------------------------------------------------------------------------------------------- | -------- | ---------------------- | | **B2-0** | **Done 2026-08-28.** Alpha.4 verified; `dev` synced (#1432); `environment: release`; ENV-03; `dev` `strict: true` | hours | — | | **B2-1** | Capture the epoch-1 fixtures; retire `voice_speakers` and `member_leave` | 1 day | B2-6, B2-7 | -| **B2-2** | Protocol epoch and negotiation | 1 day | serialized, after B2-8 | -| **B2-3** | Server-first updates through the signed manifest | ½ day | after B2-2 | -| **B2-4** | Compatibility matrix | ½ day | after B2-2 | +| **B2-2** | Protocol epoch and negotiation — **DONE 2026-08-29 (slim; absorbs B2-3, B2-4)** | 1 day | serialized, after B2-8 | +| **B2-3** | Server-first updates through the signed manifest — folded into B2-2 | ½ day | after B2-2 | +| **B2-4** | Compatibility matrix — folded into B2-2 | ½ day | after B2-2 | | **B2-5** | One permission predicate per security property | 1–2 days | serialized | | **B2-6** | Safe audit coverage | ½ day | B2-1, B2-7 | | **B2-7** | Trust model, absence proofs, plugin boundary | 1 day | B2-1, B2-6 | @@ -234,93 +234,84 @@ runs the new test under `go test ./...`), `npm run check:client`. ## B2-2 — Protocol epoch and negotiation -Design fixed 2026-08-28; the numbers and names below are the contract. +**Owner decision 2026-08-29: shipped slim.** The one-epoch policy below +replaces the three-wide window (N-2..N), the `server-info` endpoint, the +obligations table and the per-epoch fixture matrix that this section +specified on 2026-08-28. Reason: OwnCord is alpha with one maintainer; a +window is a standing promise that every future protocol change must keep two +older transcripts replaying, and nothing today needs it. Each dropped piece is +one constant or one handler away if it is ever wanted; the owner's earlier +"execute B2 as written" decision is knowingly walked back for this step only. -1. **One number, generated.** `protocol/schema.json` gains - `"protocol_epoch": 1` beside `"version": 1` (schema format stays 1). - `Server/cmd/genprotocol/main.go` reads it (`ProtocolEpoch int` on the schema - struct at lines 34–37) and emits `const ProtocolEpoch = 1` into - `Server/ws/message_types.go` and `export const PROTOCOL_EPOCH = 1` into - `Client/src/lib/protocolTypes.ts`. Follow the `protocol-change` skill; the - drift gate covers both outputs. Own commit. -2. **Handshake, server.** In `Server/ws/serve_auth.go` the auth payload gains - `` Epoch int `json:"epoch"` `` and `` ClientVersion string `json:"client_version"` ``. - Rule, with N = `ProtocolEpoch`: absent → 0; accept when - `max(0, N-2) ≤ epoch ≤ N`; otherwise `buildAuthError` - (`Server/ws/messages.go:368`) with `code: "protocol_epoch_unsupported"`, - `server_epoch`, `min_epoch`, `update_url` (the server's own - `/api/v1/client-update` origin), then close with code **4426**. A client - newer than the server gets the same frame. `ready` - (`Server/ws/serve_ready.go:361`) gains `protocol_epoch`. Table test over - epoch ∈ {absent, N-3, N-2, N-1, N, N+1} in `Server/ws`. -3. **Handshake, client.** `Client/src/lib/ws.ts:447` sends `epoch: -PROTOCOL_EPOCH` and `client_version` (from the app metadata the settings - Logs tab already reads). `AuthPayload`/`AuthErrorPayload` in - `Client/src/lib/types.ts` gain the fields. On - `code === "protocol_epoch_unsupported"` the dispatcher - (`Client/src/lib/dispatcher.ts:293`) shows one plain line naming - `server_epoch` and whether the client or the server is the older one; the - existing non-recoverable path (`ws.ts:306`) already stops reconnecting — - add the unit test that proves no reconnect timer is armed after the frame. - Extend `Client/tests/contract/ws-auth-frame.test.ts` from B2-1. -4. **`GET /api/v1/server-info`.** New unauthenticated handler beside - `Server/api/client_update.go`, returning - `{ "version", "protocol_epoch", "min_client_epoch" }`, rate-limited the way - `client-update` is (`Server/api/constants.go:67`). Handler test; row in - `docs/api.md`. B6 adds the browser-hosting flag here; B8 reads it. -5. **`docs/protocol.md` § Compatibility** (new section) and a pointer in - `protocol/README.md`: within an epoch changes are additive (new optional - fields; new message types the other side may ignore — unknown server→client - types are ignored, unknown client→server types get an `error` frame; both - pinned by tests); a breaking change is a new epoch; the epoch-1 fixtures - replay against the server for as long as epoch 1 is in the window, and a - failing fixture means "bump the epoch", not "fix the fixture". An - **obligations table** with dates, first row: the headerless E2EE key-offer - blob (`docs/protocol.md` ~line 1235 today says "scheduled for removal in the - next release") stays parseable until epoch 0 leaves the window, i.e. until - N = 3. It is client↔client through a relay, so the server's epoch cannot - police it; a written date does. -6. **Scope of "negotiation".** Epoch only. No capability flags at beta; a - client either speaks the epoch or it does not. The roadmap's "protocol - changelog" is the obligations table above plus the `CHANGELOG.md` entry - that ships the epoch. +What shipped (branch `feat/b2-2-protocol-epoch` from `dev` `e6c6bf12`, +pre-squash SHAs for HP-2): -Four commits (schema+generator; server; client; server-info+docs). Record the -pre-squash SHAs in HP-2 — the fixture commit from B2-1 and the negotiation -commits must be reviewable apart. +1. **One number, generated** — `2ac9b5ba`. `protocol/schema.json` gains + `"protocol_epoch": 1`; `genprotocol` validates it (>= 1) and emits + `const ProtocolEpoch = 1` and `export const PROTOCOL_EPOCH = 1;`. + `TestProtocolEpochMatchesSchema` pins the Go constant to the schema. +2. **Handshake, server** — `77051648`. `auth` gains `epoch` (absent = 0). + Rule: accept `minClientEpoch <= epoch <= ProtocolEpoch`, with + `minClientEpoch = 0` for epoch 1 only (alpha.4 clients send no epoch). + Otherwise `auth_error` with `code: "protocol_epoch_unsupported"`, + `client_epoch`, `server_epoch`, `min_epoch`, a message naming which side + to update, then the same 1008 close as every handshake failure — **no + 4426**, nothing reads close codes. `ready` is unchanged (fixtures + untouched). `TestAuth_ProtocolEpoch` drives absent/0/N/N+1/-1 over a real + socket; `TestEpoch1Fixtures` still passes unmodified, which is the + "old client still works" check B2-1 left open — answered as: the epoch-1 + transcript replays verbatim, no additive tolerance needed because the + accepted frames did not change. +3. **Handshake, client** — `41ef091d`. `ws.ts` sends `epoch: PROTOCOL_EPOCH` + (`ws-auth-frame.test.ts` extended on purpose). On the refusal with a newer + server, the dispatcher records the host in `ui.store.updateRequiredHost` + and `main.ts` mounts `UpdateNotifier` on the connect page, so the refused + client gets the same Update Now banner it would have had on the main page. + No `client_version` field: nothing reads it. +4. **Server-first updates (was B2-3)** — `899c956f`. The signed + server-update manifest gains `protocol_epoch`, written by `release.yml` + from `jq .protocol_epoch protocol/schema.json`. + `Updater.ReleaseProtocolEpoch` verifies the manifest through the existing + minisign path and reads it; `GET /api/v1/client-update` answers 204 when + the release's epoch is newer than `ws.ProtocolEpoch` or the manifest does + not verify. Releases without a manifest are epoch 0 (advertised as + before), so the existing updater-contract tests did not move. Dropped + from the B2-3 spec: the "fall back to the newest compatible release" + search — the endpoint only knows the latest release, and a held-back + release simply waits for the server to upgrade. + Docs: `docs/protocol.md` § Compatibility (protocol epoch), `docs/api.md`, + `docs/deployment.md` § Upgrading, `protocol/README.md`, `CHANGELOG.md` + Unreleased. + +Answers to B2-1's open questions: the captured wire is **epoch 1**; "absent +`epoch`" is the number 0 and is accepted by epoch-1 servers only. + +Not shipped, and why: + +| Spec item | Status | Reason | +| ---------------------------------- | ------- | ------------------------------------------------------------------------------------------- | +| Window `max(0, N-2) <= epoch <= N` | dropped | One epoch by policy; `minClientEpoch` is the knob (`Server/ws/messages.go`) | +| Close code 4426 | dropped | Payload `code` is what the client reads; 1008 keeps auth-failure uniform | +| `ready.protocol_epoch` | dropped | Nothing consumes it; would regenerate every fixture carrying `ready` | +| `client_version` in `auth` | dropped | Diagnostics only; add with the first reader | +| `GET /api/v1/server-info` | dropped | B6/B8 add it when they need it; the refusal frame already carries `server_epoch` | +| Obligations table (E2EE blob date) | dropped | Only meaningful with a window; the blob note in `docs/protocol.md` stands as written | +| B2-4 compatibility matrix | folded | `TestAuth_ProtocolEpoch` is the accept/reject table; fixtures replay for the accepted epoch | +| B2-3 newest-compatible fallback | dropped | See item 4 | ## B2-3 — Server-first updates -1. `releaseManifest` (`Server/updater/verify.go:29`) gains - `` ProtocolEpoch int `json:"protocol_epoch,omitempty"` `` and - `` ClientVersion string `json:"client_version,omitempty"` ``. A missing field - is epoch 0. -2. `.github/workflows/release.yml`, step "Generate server update manifest" - (~line 568): write both fields, reading the epoch from - `jq .protocol_epoch protocol/schema.json` so the workflow cannot drift from - the generated constant. -3. `Server/api/client_update.go`: before advertising a release, fetch and - verify its manifest through the updater's existing signature path and - require `manifest.ProtocolEpoch <= ws.ProtocolEpoch`; otherwise fall back - to the newest compatible release; otherwise 204. Tests: newer-epoch - candidate skipped → older compatible advertised → none → 204; unsigned or - tampered manifest → candidate ignored. -4. Docs: `docs/api.md` client-update section (the filter and the manifest - fields); one paragraph "the server upgrades first" in `docs/deployment.md`; - and the next tag line, `v1.2.0-beta.1`, recorded as the heading - `CHANGELOG.md`'s next entry will use and in one sentence in - `docs/contributing.md` (no release-procedure document exists today; do - not create one for this). +Folded into B2-2 item 4 (`899c956f`). The `v1.2.0-beta.1` tag-line note for +`CHANGELOG.md`/`docs/contributing.md` was not written: the changelog entry is +under `## Unreleased` and takes the tag when one is cut. ## B2-4 — Compatibility matrix -Extend `Server/ws/protocol_epoch1_contract_test.go` from B2-1 into the matrix: -for each client epoch in {absent, N-3, N-2, N-1, N, N+1} connect and, for -accepted epochs, replay every epoch-1 fixture; for rejected ones, assert the -`protocol_epoch_unsupported` frame and close code 4426. It runs under -`go test ./...`, inside the required `Server Build & Test` check — **no new CI -job and no pin-script change.** Exit evidence for BPR-032 and BG-07's server -half. +Folded into B2-2 item 2. With one accepted epoch there is no matrix: the +table test covers absent/0/N/N+1/-1 on a real socket, and the epoch-1 +fixtures replay for the accepted epoch under the required +`Server Build & Test` check. Exit evidence for BPR-032 and BG-07's server +half stands on those two tests. ## B2-5 — One permission predicate per security property diff --git a/docs/protocol.md b/docs/protocol.md index 1a17d8fc..a86b0fe7 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -138,6 +138,7 @@ After the WebSocket connection is established, the client sends the first messag | `token` | string | Yes | Session token obtained from `POST /api/v1/auth/login` | | `last_seq` | uint64 | No | Last sequence number received. If > 0, server attempts replay. Default 0. | | `active_channel_id` | int64 | No | The channel the client had open when it disconnected. Honoured only on a resume (`last_seq > 0`) and only after the server re-checks read permission; an unknown or unreadable id is ignored. Omit when unknown. | +| `epoch` | int | No | The wire epoch this client speaks (`PROTOCOL_EPOCH`, generated from `protocol/schema.json`). Absent means 0. See [Compatibility](#compatibility-protocol-epoch). | `active_channel_id` closes a resume-only gap. The hub restores a reconnecting client's channel subscription by copying it from the previous connection entry, @@ -210,6 +211,54 @@ expired`, and `user not found`. After sending `auth_error`, the server closes the connection with close code **1008** (policy violation) and reason `authentication failed`. +One refusal carries more than `message`. When the client's `epoch` is outside +the range the server accepts, the payload is: + +```json +{ + "type": "auth_error", + "payload": { + "message": "this client speaks protocol epoch 0 but the server needs 2; update the client", + "code": "protocol_epoch_unsupported", + "client_epoch": 0, + "server_epoch": 2, + "min_epoch": 2 + } +} +``` + +`message` names which side to update; the numbers let a client decide for +itself (`server_epoch > client_epoch` — the client is the older side). The +close that follows is the same 1008. + +### Compatibility (protocol epoch) + +The protocol has one version number, the **epoch**, declared once as +`protocol_epoch` in `protocol/schema.json` and generated into +`ws.ProtocolEpoch` (server) and `PROTOCOL_EPOCH` (client). The client sends it +in `auth`; the server accepts an `epoch` in `[min_epoch, server_epoch]` and +refuses anything else with `protocol_epoch_unsupported`. + +- **Within an epoch, changes are additive.** New optional fields; new message + types the other side may ignore. Unknown server→client types are ignored by + the client; unknown client→server types get an `error` frame. The frozen + transcripts under `protocol/fixtures/epoch-1/` replay against the server for + as long as epoch 1 is accepted — a failing fixture means "bump the epoch", + not "fix the fixture". +- **A breaking change is a new epoch.** Bump `protocol_epoch`, regenerate, and + set `minClientEpoch` (`Server/ws/messages.go`) to the new value: the server + accepts exactly one epoch by policy. Epoch 1 additionally accepts an absent + `epoch` (0), because clients up to v1.2.0-alpha.4 predate the field. +- **The server upgrades first.** A release's signed server-update manifest + carries its `protocol_epoch`, and `GET /api/v1/client-update` never + advertises a release whose epoch is newer than the server's own — a client + that auto-updated onto it would be refused at the next handshake. Releases + that do not bump the epoch (fixes, additive features) reach clients whether + or not the server has been updated. +- **A refused client can still update in place.** On + `protocol_epoch_unsupported` with a newer server, the desktop client shows + the regular update banner on the connect page. + ### Step 4: ready Payload After `auth_ok`, the server sends a `ready` message containing all initial state. diff --git a/protocol/README.md b/protocol/README.md index 4cb4e09a..7377065b 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -17,6 +17,11 @@ Two files are generated from it and must never be hand-edited: ## Changing the protocol +`schema.json` also declares `protocol_epoch`, the one version number the auth +handshake negotiates on. Additive changes stay within an epoch; a breaking +change bumps it. The rules, and what a bump obliges, are in `docs/protocol.md` +under _Compatibility_. + Edit `schema.json`, then regenerate both consumers with one command from the repository root: diff --git a/protocol/schema.json b/protocol/schema.json index d8c06153..14365693 100644 --- a/protocol/schema.json +++ b/protocol/schema.json @@ -1,6 +1,7 @@ { "$comment": "Single source of truth for WebSocket protocol message-type constants. Server/ws/message_types.go and Client/src/lib/protocolTypes.ts are generated from this file — edit here, then run `npm run generate` from the repository root (or `make protocol-generate` in Server/). CI runs `make protocol-verify` to reject drift.", "version": 1, + "protocol_epoch": 1, "client_to_server": [ { "wire": "auth", "go": "MsgTypeAuth", "ts": "AUTH" }, { "wire": "chat_send", "go": "MsgTypeChatSend", "ts": "CHAT_SEND" },