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).
This commit is contained in:
J3vb
2026-08-29 07:23:06 +02:00
committed by GitHub
parent e6c6bf12bf
commit 9c9b8be669
36 changed files with 766 additions and 108 deletions
+6
View File
@@ -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
+7 -2
View File
@@ -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
+25
View File
@@ -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
+11 -3
View File
@@ -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");
}),
);
+4
View File
@@ -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
// ---------------------------------------------------------------------------
+11
View File
@@ -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`).
+2
View File
@@ -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 } : {}),
},
});
+37 -7
View File
@@ -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<void> {
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<void> {
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");
+6 -1
View File
@@ -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;
+11
View File
@@ -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<string>;
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,
+18 -10
View File
@@ -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<string, unknown> } {
@@ -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);
});
});
+1
View File
@@ -72,6 +72,7 @@ const UI_INITIAL: UiState = {
connectionStatus: "disconnected",
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set(),
sidebarMode: "channels",
activeDmUserId: null,
@@ -71,6 +71,7 @@ function resetStores(): void {
connectionStatus: "connected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
+48 -1
View File
@@ -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: [
+94
View File
@@ -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");
});
});
+1
View File
@@ -204,6 +204,7 @@ function resetStores(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
@@ -36,6 +36,7 @@ function resetStores(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
@@ -23,6 +23,7 @@ function resetStores(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
+1
View File
@@ -28,6 +28,7 @@ function resetStore(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
@@ -57,6 +57,7 @@ function resetStores(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
+11
View File
@@ -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
+73
View File
@@ -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())
}
}
+10
View File
@@ -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)
+73
View File
@@ -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")
}
})
}
+32
View File
@@ -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
}
+4
View File
@@ -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"
+34
View File
@@ -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.
// ---------------------------------------------------------------------------
+18
View File
@@ -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)
}
}
+73
View File
@@ -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()
})
}
}
+8
View File
@@ -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)
+6 -1
View File
@@ -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.
---
+8
View File
@@ -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
@@ -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 | 12 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 3437) 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
+49
View File
@@ -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.
+5
View File
@@ -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:
+1
View File
@@ -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" },