Files
J3vb 9c9b8be669 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).
2026-08-29 07:23:06 +02:00

4.4 KiB

protocol/

The cross-component WebSocket contract. It lives at the repository root because neither side owns it: schema.json is the single source of truth for the message-type constants both the Go server and the TypeScript client compile against.

File Role
schema.json Source of truth. Every wire message type, both directions
fixtures/epoch-1/ Frozen wire transcripts for protocol epoch 1. Regenerated by the test, never hand-edited

Two files are generated from it and must never be hand-edited:

  • Server/ws/message_types.go
  • Client/src/lib/protocolTypes.ts

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:

npm run generate

(Equivalently, make protocol-generate or go run ./cmd/genprotocol from Server/ — the generator is a Go program, so it lives where the Go toolchain already runs.)

Three gates reject a stale regeneration and one gate checks the schema against the constants independently — .githooks/pre-commit, make protocol-verify in CI, npm run check:server, and Server/ws/protocol_contract_test.go. There is nothing extra to run.

Fixtures

fixtures/epoch-1/ holds one JSON file per journey (fresh connect, chat send, voice join, ...). Within a file, each connection has its own list of frames in order; frames on different connections are not related to each other — cross-connection interleaving is timing-dependent and is not part of the contract. Volatile values — ids, seqs, timestamps, tokens, user ids — are replaced by typed placeholders such as "<id:number>", so a refactor that keeps the wire shape leaves the file untouched, while a renamed, added or removed key, or a type change, shows up as a diff.

TestEpoch1Fixtures in Server/ws drives the real server in-process and compares its output against these files. It runs under go test ./..., so npm run check:server and the Server Build & Test CI check already cover it — there is nothing extra to run.

Regenerate with:

go test ./ws -run TestEpoch1Fixtures -update

from Server/, then read the diff frame by frame before committing it.

A fixture's shape may only change deliberately — and an epoch bump is for what older clients cannot process. Within an epoch, a change an older client can ignore — a new key, a new optional field — is allowed: regenerate fixtures/epoch-<n>/ in the same PR, read the diff frame by frame, and document the addition in docs/protocol.md. A change an older client cannot process — a key removed, renamed, or retyped, a frame dropped, or the order of frames on one connection changed — needs a new fixtures/epoch-<n+1>/ directory, with the old one kept as the record of what earlier clients speak. A diff of that kind in a change that does not bump the epoch is a protocol break to revert, not a refactor to accept. (Epoch negotiation itself is an additive change and stays on epoch 1.)

A diff confined to a seeded default value is not a protocol change. The fixtures record real values wherever they are deterministic — role permission masks and colours, motd, server_name, a channel's voice_max_video, the voice_config preset — so a migration that changes a default mask moves a fixture without touching the wire. Regenerate in the same PR and read the diff frame by frame. Normalising those values away is not the answer: a placeholder over a mask or over an enum such as voice_config.threshold_mode would hide exactly the drift these files exist to catch.

A value drawn from a fixed vocabulary the client switches on — threshold_mode, quality, status, replay_source, a channel type — is shape, not a seeded value: renaming or dropping a member of it is a protocol change even though only a value moved.

The narrative protocol reference is docs/protocol.md; the blueprint is docs/architecture/websocket.md.