Files
OwnCord/protocol/schema.json
T
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

113 lines
6.2 KiB
JSON

{
"$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" },
{ "wire": "chat_edit", "go": "MsgTypeChatEdit", "ts": "CHAT_EDIT" },
{ "wire": "chat_delete", "go": "MsgTypeChatDelete", "ts": "CHAT_DELETE" },
{ "wire": "reaction_add", "go": "MsgTypeReactionAdd", "ts": "REACTION_ADD" },
{ "wire": "reaction_remove", "go": "MsgTypeReactionRemove", "ts": "REACTION_REMOVE" },
{ "wire": "typing_start", "go": "MsgTypeTypingStart", "ts": "TYPING_START" },
{ "wire": "channel_focus", "go": "MsgTypeChannelFocus", "ts": "CHANNEL_FOCUS" },
{ "wire": "mark_read", "go": "MsgTypeMarkRead", "ts": "MARK_READ" },
{ "wire": "presence_update", "go": "MsgTypePresenceUpdate", "ts": "PRESENCE_UPDATE" },
{ "wire": "voice_join", "go": "MsgTypeVoiceJoin", "ts": "VOICE_JOIN" },
{ "wire": "voice_leave", "go": "MsgTypeVoiceLeave", "ts": "VOICE_LEAVE" },
{ "wire": "voice_mute", "go": "MsgTypeVoiceMute", "ts": "VOICE_MUTE" },
{ "wire": "voice_deafen", "go": "MsgTypeVoiceDeafen", "ts": "VOICE_DEAFEN" },
{ "wire": "voice_camera", "go": "MsgTypeVoiceCamera", "ts": "VOICE_CAMERA" },
{ "wire": "voice_screenshare", "go": "MsgTypeVoiceScreenshare", "ts": "VOICE_SCREENSHARE" },
{ "wire": "voice_mod_mute", "go": "MsgTypeVoiceModMute", "ts": "VOICE_MOD_MUTE" },
{ "wire": "voice_mod_deafen", "go": "MsgTypeVoiceModDeafen", "ts": "VOICE_MOD_DEAFEN" },
{ "wire": "voice_mod_move", "go": "MsgTypeVoiceModMove", "ts": "VOICE_MOD_MOVE" },
{ "wire": "voice_mod_kick", "go": "MsgTypeVoiceModKick", "ts": "VOICE_MOD_KICK" },
{ "wire": "ping", "go": "MsgTypePing", "ts": "PING" },
{
"wire": "voice_token_refresh",
"go": "MsgTypeVoiceTokenRefresh",
"ts": "VOICE_TOKEN_REFRESH",
"go_trailing_comment": "//nolint:gosec // G101: false positive — message type constant, not a credential"
},
{
"wire": "voice_e2ee_announce",
"go": "MsgTypeVoiceE2EEAnnounce",
"ts": "VOICE_E2EE_ANNOUNCE"
},
{ "wire": "voice_e2ee_offer", "go": "MsgTypeVoiceE2EEOffer", "ts": "VOICE_E2EE_OFFER" },
{ "wire": "call_ring", "go": "MsgTypeCallRing", "ts": "CALL_RING" },
{ "wire": "call_decline", "go": "MsgTypeCallDecline", "ts": "CALL_DECLINE" },
{
"wire": "chat_command",
"go": "MsgTypeChatCommand",
"ts": "CHAT_COMMAND",
"note": "plugin slash-command dispatch (Phase C)"
}
],
"server_to_client": [
{ "wire": "auth_ok", "go": "MsgTypeAuthOK", "ts": "AUTH_OK" },
{ "wire": "auth_error", "go": "MsgTypeAuthError", "ts": "AUTH_ERROR" },
{ "wire": "ready", "go": "MsgTypeReady", "ts": "READY" },
{ "wire": "chat_message", "go": "MsgTypeChatMessage", "ts": "CHAT_MESSAGE" },
{ "wire": "chat_send_ok", "go": "MsgTypeChatSendOK", "ts": "CHAT_SEND_OK" },
{ "wire": "chat_edited", "go": "MsgTypeChatEdited", "ts": "CHAT_EDITED" },
{ "wire": "chat_deleted", "go": "MsgTypeChatDeleted", "ts": "CHAT_DELETED" },
{ "wire": "chat_bulk_deleted", "go": "MsgTypeChatBulkDeleted", "ts": "CHAT_BULK_DELETED" },
{ "wire": "reaction_update", "go": "MsgTypeReactionUpdate", "ts": "REACTION_UPDATE" },
{ "wire": "typing", "go": "MsgTypeTyping", "ts": "TYPING" },
{ "wire": "presence", "go": "MsgTypePresence", "ts": "PRESENCE" },
{ "wire": "channel_create", "go": "MsgTypeChannelCreate", "ts": "CHANNEL_CREATE" },
{ "wire": "channel_update", "go": "MsgTypeChannelUpdate", "ts": "CHANNEL_UPDATE" },
{ "wire": "channel_delete", "go": "MsgTypeChannelDelete", "ts": "CHANNEL_DELETE" },
{ "wire": "voice_state", "go": "MsgTypeVoiceState", "ts": "VOICE_STATE" },
{ "wire": "voice_config", "go": "MsgTypeVoiceConfig", "ts": "VOICE_CONFIG" },
{ "wire": "voice_token", "go": "MsgTypeVoiceToken", "ts": "VOICE_TOKEN" },
{
"wire": "voice_leave",
"go": "MsgTypeVoiceLeaveBC",
"ts": "VOICE_LEAVE",
"note": "broadcast (same string as client msg)"
},
{ "wire": "voice_moved", "go": "MsgTypeVoiceMoved", "ts": "VOICE_MOVED" },
{ "wire": "voice_disconnected", "go": "MsgTypeVoiceDisconnected", "ts": "VOICE_DISCONNECTED" },
{ "wire": "member_join", "go": "MsgTypeMemberJoin", "ts": "MEMBER_JOIN" },
{ "wire": "member_update", "go": "MsgTypeMemberUpdate", "ts": "MEMBER_UPDATE" },
{ "wire": "user_update", "go": "MsgTypeUserUpdate", "ts": "USER_UPDATE" },
{ "wire": "member_ban", "go": "MsgTypeMemberBan", "ts": "MEMBER_BAN" },
{ "wire": "roles_update", "go": "MsgTypeRolesUpdate", "ts": "ROLES_UPDATE" },
{ "wire": "emoji_update", "go": "MsgTypeEmojiUpdate", "ts": "EMOJI_UPDATE" },
{ "wire": "server_restart", "go": "MsgTypeServerRestart", "ts": "SERVER_RESTART" },
{ "wire": "error", "go": "MsgTypeError", "ts": "ERROR" },
{ "wire": "pong", "go": "MsgTypePong", "ts": "PONG" },
{ "wire": "dm_channel_open", "go": "MsgTypeDMChannelOpen", "ts": "DM_CHANNEL_OPEN" },
{ "wire": "dm_channel_close", "go": "MsgTypeDMChannelClose", "ts": "DM_CHANNEL_CLOSE" },
{ "wire": "call_incoming", "go": "MsgTypeCallIncoming", "ts": "CALL_INCOMING" },
{ "wire": "call_declined", "go": "MsgTypeCallDeclined", "ts": "CALL_DECLINED" },
{
"wire": "voice_e2ee_announce",
"go": "MsgTypeVoiceE2EEAnnounceBC",
"ts": "VOICE_E2EE_ANNOUNCE",
"note": "broadcast (same string as client msg)"
},
{
"wire": "voice_e2ee_offer",
"go": "MsgTypeVoiceE2EEOfferRelay",
"ts": "VOICE_E2EE_OFFER",
"note": "relay (same string as client msg)"
},
{
"wire": "command_reply",
"go": "MsgTypeCommandReply",
"ts": "COMMAND_REPLY",
"note": "ephemeral plugin reply, sent only to the invoking client"
},
{
"wire": "plugin_broadcast",
"go": "MsgTypePluginBroadcast",
"ts": "PLUGIN_BROADCAST",
"note": "plugin channel broadcast, gated by the sender's SEND_MESSAGES"
}
]
}