mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 | 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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" },
|
||||
|
||||
Reference in New Issue
Block a user