Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений

Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство,
очередь per-device с фан-аутом без эха отправителю, POST /api/messages
с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди,
ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении,
лимит 30 сообщений в минуту.

Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD,
sync.js как единственный писатель в IndexedDB, ACK строго после записи,
список чатов, экран чата по эталону, разделители дат и «новые»,
pending и failed с повтором, полоса «нет соединения».

ADR-033: у неотправленного есть текст отказа — clock_skew стало видно.
ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает
открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть
до стирания своего присланного, чего «удалить у всех не существует» не допускает.
ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel):
две вкладки отбирали поток друг у друга и оставались без живой доставки.
ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, —
иначе потерянный ответ давал у собеседника два сообщения вместо одного.

Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка,
копия на второе устройство, очередь офлайн-устройству, ACK, подмена from
игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста
в базе и WAL ноль вхождений.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
2026-08-22 18:18:04 +03:00
co-authored by Claude Opus 5
parent 597c55301c
commit 63a7a1ef52
39 changed files with 5126 additions and 46 deletions
+73
View File
@@ -0,0 +1,73 @@
// Список чатов в сайдбаре — docs/ui.md, «Список чатов».
//
// Секции «каналы» и «личные», порядок — по lastId по убыванию (его держит
// sync.chats). Пустая секция не рисуется: комнат до этапа 3 нет.
import * as sync from "../sync.js";
import { clear, el } from "./dom.js";
const SECTIONS = [
["room", "каналы"],
["dm", "личные"],
];
// mount рисует список в root и держит его в актуальном виде, пока экран
// жив. Отдаёт отписку.
export function mount(root, ctx, active) {
// Событий «chats» приходит больше одного подряд; рисует последнее.
let generation = 0;
const paint = async () => {
const mine = ++generation;
let list;
try {
list = await sync.chats();
} catch {
return;
}
if (mine !== generation) {
return;
}
clear(root);
for (const [type, title] of SECTIONS) {
const part = list.filter((chat) => chat.type === type);
if (part.length === 0) {
continue;
}
const items = el("ul", "items");
for (const chat of part) {
items.append(item(ctx, chat, active));
}
root.append(el("h2", "section", title), items);
}
};
const off = sync.on("chats", paint);
paint();
return off;
}
function item(ctx, chat, active) {
const row = el("li");
const button = el("button", "item");
button.type = "button";
if (chat.id === active) {
// Активный чат — инверсия (docs/identity/brief.md).
button.classList.add("is-active");
button.setAttribute("aria-current", "true");
}
button.append(el("span", "item__name", sigil(chat) + chat.title));
if (chat.unread > 0) {
button.append(el("span", "n", String(chat.unread)));
}
button.addEventListener("click", () => ctx.go(hashOf(chat)));
row.append(button);
return row;
}
// Сигил ставит экран: в базе чат зовётся без «@» и «#» (docs/storage.md).
function sigil(chat) {
return chat.type === "dm" ? "@" : "#";
}
function hashOf(chat) {
return chat.type === "dm" ? `#/dm/${chat.peer}` : `#/room/${chat.roomId}`;
}