Этап 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
+253
View File
@@ -59,6 +59,20 @@ function value(request) {
});
}
// get и put — одна запись одного хранилища. Ключ у chats, messages
// и peers лежит внутри значения (keyPath), поэтому put берёт запись целиком.
async function get(name, key) {
const db = await open();
return value(db.transaction(name, "readonly").objectStore(name).get(key));
}
async function put(name, record) {
const db = await open();
const tx = db.transaction(name, "readwrite");
tx.objectStore(name).put(record);
await done(tx);
}
// meta читает несколько ключей одной транзакцией.
export async function meta(keys) {
const db = await open();
@@ -81,6 +95,245 @@ export async function putMeta(entries) {
await done(tx);
}
// --- чаты ---------------------------------------------------------------
// PAGE — страница ленты: 50 сообщений (docs/storage.md).
export const PAGE = 50;
const DM = "dm:";
const ROOM = "room:";
// Ключ чата — "dm:<собеседник>" или "room:<roomId>" (docs/storage.md).
// Это не метка чата в AAD сообщения: там у личного чата оба ника.
export function dmChatId(peer) {
return DM + peer;
}
export function roomChatId(roomId) {
return ROOM + roomId;
}
// peerOf — с кем личный чат; у комнаты собеседника нет.
export function peerOf(chatId) {
return chatId.startsWith(DM) ? chatId.slice(DM.length) : null;
}
// blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»:
// сигил ставит экран. Комнате имя приходит из GET /api/rooms (этап 3),
// до этого вместо имени стоит идентификатор.
export function blankChat(id) {
const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false };
const peer = peerOf(id);
if (peer !== null) {
return { ...base, type: "dm", title: peer, peer };
}
const roomId = id.slice(ROOM.length);
return { ...base, type: "room", title: roomId, roomId };
}
// chats — список чатов в порядке docs/ui.md: по lastId по убыванию.
// Скрытые («убрать из списка») не отдаются, пока их не попросят.
export async function chats({ hidden = false } = {}) {
const db = await open();
const store = db.transaction("chats", "readonly").objectStore("chats");
const list = await value(store.getAll());
return list.filter((c) => hidden || !c.hidden).sort(byLastId);
}
function byLastId(a, b) {
if (a.lastId !== b.lastId) {
if (!a.lastId) {
return 1;
}
if (!b.lastId) {
return -1;
}
return a.lastId < b.lastId ? 1 : -1;
}
return a.id < b.id ? -1 : 1;
}
export function chat(id) {
return get("chats", id);
}
export function putChat(record) {
return put("chats", record);
}
// markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает
// к последнему сообщению. Обе величины локальные, на сервер не уходят
// (docs/storage.md).
export async function markRead(chatId) {
const db = await open();
const tx = db.transaction("chats", "readwrite");
const store = tx.objectStore("chats");
const record = await value(store.get(chatId));
if (record) {
record.unread = 0;
record.lastReadId = record.lastId;
store.put(record);
}
await done(tx);
return record ?? null;
}
// hideChat прячет чат из списка или возвращает его туда. История
// не трогается: «убрать из списка» — не удаление (ADR-019).
export async function hideChat(chatId, hidden) {
const db = await open();
const tx = db.transaction("chats", "readwrite");
const store = tx.objectStore("chats");
const record = (await value(store.get(chatId))) ?? blankChat(chatId);
record.hidden = hidden;
store.put(record);
await done(tx);
return record;
}
// --- сообщения ----------------------------------------------------------
export function message(id) {
return get("messages", id);
}
// saveMessages пишет сообщения и обновляет их чаты одной транзакцией.
// ACK серверу уходит только после успешной записи (docs/storage.md),
// поэтому лента и счётчик непрочитанных не должны расходиться.
//
// remove — идентификаторы, которые надо убрать: устаревший ULID
// неотправленного сообщения меняется на свежий, и старая запись уходит
// (ADR-036).
// me — собственный ник: свои сообщения непрочитанными не считаются.
// incoming — сообщения пришли из потока событий: известный id
// игнорируется целиком, перезаписи нет (ADR-034).
//
// Отдаёт ключи затронутых чатов.
export async function saveMessages({
messages = [],
remove = [],
me = null,
incoming = false,
} = {}) {
if (messages.length === 0 && remove.length === 0) {
return [];
}
const db = await open();
const tx = db.transaction(["messages", "chats"], "readwrite");
const store = tx.objectStore("messages");
const chatStore = tx.objectStore("chats");
for (const id of remove) {
store.delete(id);
}
// Оба чтения — запросы этой же транзакции: она живёт, пока их ждут.
const known = await Promise.all(messages.map((m) => value(store.get(m.id))));
const ids = [...new Set(messages.map((m) => m.chatId))];
const records = await Promise.all(ids.map((id) => value(chatStore.get(id))));
const touched = new Map();
ids.forEach((id, i) => touched.set(id, records[i] ?? blankChat(id)));
// Повтор доставки не должен ни дублировать ленту, ни двигать счётчик:
// сервер выдаёт очередь заново при каждом подключении и вправе
// прислать конверт дважды в одной пачке (ADR-017). Дубли внутри пачки
// видны только здесь: known собран до первого put.
const seen = new Set();
messages.forEach((m, i) => {
const twice = seen.has(m.id);
seen.add(m.id);
// Входящее с уже известным id игнорируется целиком: id открыт
// в конверте, и перезапись отдала бы собеседнику чужую запись
// в истории (ADR-034). Исходящее по своему id пишется всегда —
// это переход pending → sent/failed.
if (twice || (incoming && known[i] !== undefined)) {
return;
}
store.put(m);
const record = touched.get(m.chatId);
if (!record.lastId || record.lastId < m.id) {
record.lastId = m.id;
}
if (known[i] !== undefined) {
return;
}
if (m.from !== me && (!record.lastReadId || record.lastReadId < m.id)) {
record.unread += 1;
}
// Новое сообщение возвращает скрытый чат в список.
record.hidden = false;
});
for (const record of touched.values()) {
chatStore.put(record);
}
await done(tx);
return [...touched.keys()];
}
// messagesBefore — страница ленты назад от before, не включая его,
// по индексу "chat" (docs/storage.md). Отдаёт по возрастанию id.
export async function messagesBefore(chatId, before = null, limit = PAGE) {
const db = await open();
const store = db.transaction("messages", "readonly").objectStore("messages");
// Ключ индекса — [chatId, id]. Массив больше любой строки, поэтому
// [chatId, []] — верхняя граница всех сообщений чата, а [chatId] —
// нижняя: короткий массив идёт раньше своих продолжений.
const range = before
? IDBKeyRange.bound([chatId], [chatId, before], false, true)
: IDBKeyRange.bound([chatId], [chatId, []]);
const out = [];
await cursor(store.index("chat").openCursor(range, "prev"), (record) => {
out.push(record);
return out.length < limit;
});
out.reverse();
return out;
}
// pendingMessages — неотправленное по возрастанию id. Индекса по статусу
// в схеме нет (docs/storage.md), поэтому это проход курсором: он делается
// один раз при старте, дальше отправитель ведёт свой список.
export async function pendingMessages() {
const db = await open();
const store = db.transaction("messages", "readonly").objectStore("messages");
const out = [];
await cursor(store.openCursor(), (record) => {
if (record.status === "pending") {
out.push(record);
}
return true;
});
return out;
}
// cursor обходит курсор, пока step не скажет «хватит».
function cursor(request, step) {
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const current = request.result;
if (!current || !step(current.value)) {
resolve();
return;
}
current.continue();
};
request.onerror = () => reject(request.error);
});
}
// --- собеседники --------------------------------------------------------
// peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом
// получении ключа; сверка изменившегося ключа и pending — этап 3.
export function peer(nick) {
return get("peers", nick);
}
export function putPeer(record) {
return put("peers", record);
}
// persist просит браузер не вычищать базу: история на устройстве —
// единственная копия (docs/storage.md).
export async function persist() {