Files
bare/web/js/sync.js
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 00:50:58 +03:00

1641 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Транспорт и данные чата: устройство, поток событий, приём и отправка.
// Экраны берут отсюда данные и сюда же отдают действия; в db.js и api.js
// они не ходят — пишет в базу только этот модуль.
//
// Правила — docs/protocol.md («События», «Сообщения», «Комнаты»)
// и docs/storage.md: ACK уходит только после успешной записи в IndexedDB,
// исходящее живёт в pending до 202 и держится за свой ULID, пока время
// в нём годится серверу; отвергнутый по часам переиспользованный id
// меняется на свежий один раз (ADR-036).
//
// Доверие к ключам — TOFU (ADR-016): каждый публичный ключ, пришедший
// от сервера, сверяется с запомненным; изменившийся ложится в pending
// и блокирует отправку до подтверждения. Ключи комнат — docs/crypto.md,
// «Комната»: владелец заворачивает новый ключ каждому участнику, клиент
// держит все ключи комнаты и расшифровывает любым известным.
import * as api from "./api.js";
import { ApiError, NetworkError } from "./api.js";
import * as db from "./db.js";
import {
DM_KEY_ID,
dmKey,
dmLabel,
fingerprintOf,
importRoomKey,
newId,
newRoomKey,
openMessage,
roomLabel,
sealMessage,
unwrapRoomKey,
wipe,
wrapRoomKey,
} from "./crypto.js";
import { ulid, ulidTime, validUlid } from "./ulid.js";
// Пауза перед восстановлением закрытого потока: удваивается, пока
// не упрётся в предел. Живой ready сбрасывает её обратно.
const RETRY_MIN = 1000;
const RETRY_MAX = 30000;
// Владение потоком одно на браузерный профиль: устройство у вкладок общее,
// а соединение на устройство сервер держит одно (ADR-035).
const STREAM_LOCK = "bare-stream";
const CHANNEL = "bare";
// Оба API нужны вместе: замок выбирает владельца потока, канал раздаёт
// его находки остальным вкладкам. Нет хотя бы одного — работаем как
// одна вкладка (ADR-035).
const shared = typeof BroadcastChannel === "function" && !!navigator.locks;
const state = {
running: false,
nick: null,
privateKey: null,
// Свой публичный ключ: он проверен при входе, и спрашивать его у сервера
// незачем — заворачивание себе идёт по нему (docs/crypto.md, «Комната»).
publicKey: null,
device: null,
close: null, // закрыть поток событий
online: false,
timer: null,
wait: RETRY_MIN,
// Владение потоком: release отпускает замок, claim отменяет ожидание.
release: null,
claim: null,
channel: null,
// Ключи личных чатов — только в памяти: в IndexedDB они не пишутся,
// а выводятся заново из peers (docs/crypto.md, «Чат 1:1»).
keys: new Map(),
// Ключи комнат: "<roomId>|<keyId>" → CryptoKey. Это кэш над хранилищем
// roomKeys, где ключи и живут.
roomKeys: new Map(),
// Комнаты, где rekey упёрся в неподтверждённый ключ: roomId → ники
// (ADR-016). Состояние экрана, в базу не пишется.
blocked: new Map(),
// Комнаты, которым мы должны новый ключ: участник вышел, а rekey
// не прошёл. Долг поднимается и из события room, и из GET /api/rooms:
// это состояние комнаты, и офлайн владельца его не теряет (ADR-041).
// Отдаётся после ready и после подтверждения ключа (ADR-018).
owed: new Set(),
// Неотправленное. Полный проход по messages делается один раз при
// старте: индекса по статусу в схеме нет (docs/storage.md).
pending: new Set(),
// Конверты, пришедшие по SSE и ещё не разобранные.
inbox: [],
scheduled: false,
// Отложенный разбор конвертов, которые сейчас не разобрать.
inboxTimer: null,
hold: RETRY_MIN,
};
// --- события для экранов -----------------------------------------------
const bus = new EventTarget();
// on подписывает обработчик и отдаёт функцию отписки. События пять:
//
// "net" {online} — доходят ли запросы до сервера
// "chats" {} — список чатов изменился
// "messages" {chatId, ids, removed} — в чате появились, изменились
// или исчезли сообщения
// "peers" {nick} — доверие к ключу ника изменилось:
// появился pending или его подтвердили
// "rooms" {id} — комната изменилась: имя, состав,
// ключ, потребность в rekey
//
// removed непуст, только когда повтор отправки выдал сообщению новый
// ULID: старую запись из ленты надо убрать. Обычный повтор идёт с прежним
// идентификатором, removed пуст, и лента не перерисовывается (ADR-036).
export function on(type, handler) {
const wrapped = (event) => handler(event.detail);
bus.addEventListener(type, wrapped);
return () => bus.removeEventListener(type, wrapped);
}
function emit(type, detail = {}) {
bus.dispatchEvent(new CustomEvent(type, { detail }));
}
// notify рассылает изменения: экрану чата нужна лента, сайдбару — список.
// Те же изменения уходят соседним вкладкам: поток событий у профиля один,
// а база общая (ADR-035).
function notify(messages, removed = []) {
const byChat = new Map();
const slot = (chatId) => {
if (!byChat.has(chatId)) {
byChat.set(chatId, { chatId, ids: [], removed: [] });
}
return byChat.get(chatId);
};
for (const m of messages) {
slot(m.chatId).ids.push(m.id);
}
for (const r of removed) {
slot(r.chatId).removed.push(r.id);
}
const details = [...byChat.values()];
for (const detail of details) {
emit("messages", detail);
}
emit("chats");
share({
kind: "changed",
details,
// Отправленное и похороненное повтора больше не ждёт. О том, что
// осталось pending, соседям говорит settle: пока попытка идёт,
// повтор из соседней вкладки отправил бы то же сообщение второй
// раз (ADR-035).
settled: messages.filter((m) => m.status !== "pending").map((m) => m.id)
.concat(removed.map((r) => r.id)),
});
}
// announceChats — список чатов изменился без сообщений: прочитан чат,
// заведён или скрыт собеседник.
function announceChats() {
emit("chats");
share({ kind: "chats" });
}
// announcePeer — доверие к ключу ника изменилось: карточке контакта нужен
// новый отпечаток, чату — полоса про смену ключа (docs/ui.md).
function announcePeer(nick) {
emit("peers", { nick });
share({ kind: "peers", nick });
}
// announceRoom — комната изменилась: экрану участников нужен свежий состав
// и знание, чей ключ мешает rekey. Список ников едет вместе с событием:
// он живёт в памяти вкладки, а в базе его нет.
function announceRoom(id) {
emit("rooms", { id });
share({ kind: "rooms", id, blocked: needsTrust(id) });
}
// --- соседние вкладки ---------------------------------------------------
// share отдаёт изменение соседним вкладкам. Канал открыт, только пока
// синхронизация жива: после выхода база стирается, рассылать нечего.
function share(payload) {
state.channel?.postMessage(payload);
}
function openChannel() {
if (!shared || state.channel) {
return;
}
state.channel = new BroadcastChannel(CHANNEL);
state.channel.addEventListener("message", (event) => receive(event.data));
// Вкладка, открытая позже владельца, состояния сети ещё не знает.
share({ kind: "hello" });
}
function closeChannel() {
state.channel?.close();
state.channel = null;
}
// receive применяет чужое изменение: в базу оно уже записано той вкладкой,
// здесь остаётся поднять экраны. Рассылать это дальше нельзя — иначе
// сообщение ходило бы по кругу.
function receive(data) {
if (!state.running || data === null || typeof data !== "object") {
return;
}
switch (data.kind) {
case "hello":
// Отвечает владелец: только он знает, цел ли поток.
if (state.release) {
share({ kind: "net", online: state.online });
}
return;
case "net":
applyOnline(data.online === true);
return;
case "chats":
emit("chats");
return;
case "peers":
if (typeof data.nick === "string") {
// Ключ личного чата выведен из ключа собеседника и лежит в памяти
// этой вкладки: доверие изменилось — выводим заново из peers
// (docs/crypto.md, «Чат 1:1»). Без этого вкладка продолжила бы
// шифровать ключом, который человек только что отверг.
state.keys.delete(data.nick);
// Сохранённые raw перебирает владелец потока: конверты, разобранные
// устаревшим ключом до сброса, иначе остались бы нерасшифрованными.
if (state.release) {
serial(reopen);
}
emit("peers", { nick: data.nick });
}
return;
case "rooms":
if (typeof data.id === "string") {
if (Array.isArray(data.blocked) && data.blocked.length > 0) {
state.blocked.set(data.id, data.blocked);
} else {
state.blocked.delete(data.id);
}
emit("rooms", { id: data.id });
}
return;
case "pending":
// Соседняя вкладка не отправила сообщение и повторять его не будет:
// повторяет владелец потока.
for (const id of data.ids ?? []) {
state.pending.add(id);
}
return;
case "changed":
for (const id of data.settled ?? []) {
state.pending.delete(id);
}
for (const detail of data.details ?? []) {
emit("messages", detail);
}
emit("chats");
return;
default:
}
}
// --- жизненный цикл -----------------------------------------------------
// start поднимает синхронизацию после входа или восстановления сессии.
// Ключи берутся из IndexedDB: наружу они не выходят.
export async function start() {
if (state.running) {
return;
}
let meta;
try {
meta = await db.meta(["nick", "privateKey", "publicKey"]);
} catch {
return;
}
if (!meta.nick || !meta.privateKey || !meta.publicKey) {
return;
}
state.running = true;
state.nick = meta.nick;
state.privateKey = meta.privateKey;
state.publicKey = meta.publicKey;
openChannel();
try {
for (const m of await db.pendingMessages()) {
state.pending.add(m.id);
}
} catch {
// Не прочли — повторим при следующем запуске; отправка не сломана.
}
await connect();
}
// stop гасит синхронизацию: выход, удаление аккаунта, истёкшая сессия.
// Базу не трогает — это дело main.js.
export function stop() {
state.running = false;
clearTimer();
if (state.inboxTimer !== null) {
clearTimeout(state.inboxTimer);
state.inboxTimer = null;
}
if (state.close) {
state.close();
state.close = null;
}
// Замок отпускается раньше, чем гаснет всё остальное: соседняя вкладка
// ждёт очереди и займёт поток сразу (ADR-035).
yieldStream();
closeChannel();
state.nick = null;
state.privateKey = null;
state.publicKey = null;
state.device = null;
state.keys.clear();
state.roomKeys.clear();
state.blocked.clear();
state.owed.clear();
state.pending.clear();
state.inbox.length = 0;
state.wait = RETRY_MIN;
state.hold = RETRY_MIN;
setOnline(false);
}
export function online() {
return state.online;
}
export function nick() {
return state.nick;
}
export function deviceId() {
return state.device;
}
// onSent ставит обработчик успешной отправки: по первой из них клиент
// один раз просит разрешение на уведомления (docs/ui.md, «Уведомления»).
// «Первой за всю историю устройства» это делает не здесь: транспорт
// не знает ни про разрешения, ни про то, о чём уже спрашивали. Ставит
// обработчик main.js.
let sentHandler = () => {};
export function onSent(handler) {
sentHandler = handler;
}
// --- устройство ---------------------------------------------------------
// ensureDevice — deviceId устройства: 16 случайных байт base64url,
// заводится при первом входе и живёт в IndexedDB (ADR-017).
// 409 device_conflict означает, что идентификатор занят другим аккаунтом:
// берём новый.
async function ensureDevice() {
let id = (await db.meta(["deviceId"])).deviceId ?? null;
for (let attempt = 0; attempt < 3; attempt += 1) {
if (!id) {
id = newId();
await db.putMeta({ deviceId: id });
}
try {
await api.registerDevice(id);
return id;
} catch (err) {
if (err instanceof ApiError && err.code === "device_conflict") {
id = null;
continue;
}
throw err;
}
}
throw new Error("не удалось завести устройство");
}
// --- поток событий ------------------------------------------------------
async function connect() {
if (!state.running) {
return;
}
clearTimer();
try {
state.device = await ensureDevice();
} catch (err) {
// 401 unauthenticated уже увёл на экран входа и остановил нас.
if (err instanceof NetworkError) {
// Запрос не дошёл — это и есть «нет соединения» (ADR-028).
setOnline(false);
}
if (transient(err)) {
retryLater();
}
return;
}
if (state.release) {
// Поток уже наш: переподключение идёт под тем же замком.
openStream();
return;
}
claimStream();
}
// claimStream берёт владение потоком. Устройство у вкладок одного профиля
// общее (ADR-017), а соединение на устройство сервер держит одно: без
// арбитража вкладки бесконечно отбирали бы поток друг у друга. Замок
// держится, пока жива синхронизация; ожидающие вкладки живут на
// broadcast от владельца (ADR-035).
function claimStream() {
if (state.claim) {
return;
}
if (!shared) {
openStream();
return;
}
const claim = new AbortController();
state.claim = claim;
navigator.locks.request(STREAM_LOCK, { signal: claim.signal }, () => new Promise((release) => {
state.claim = null;
if (!state.running) {
release();
return;
}
state.release = release;
openStream();
})).catch(() => {
// Ожидание отменено выходом или замок не дался — потока у нас нет.
if (state.claim === claim) {
state.claim = null;
}
});
}
// yieldStream отпускает владение: соседняя вкладка займёт поток сразу.
function yieldStream() {
if (state.claim) {
state.claim.abort();
state.claim = null;
}
if (state.release) {
state.release();
state.release = null;
}
}
function openStream() {
if (state.close) {
state.close();
}
state.close = api.stream(state.device, {
msg: (envelope) => {
if (envelope) {
state.inbox.push(envelope);
schedule();
}
},
// Комнаты разбираются в общей очереди работ: приём сообщений и раздача
// ключей не должны перемешиваться.
room: (room) => {
if (room) {
serial(() => applyRoom(room));
}
},
roomLeft: (data) => {
if (typeof data?.id === "string") {
serial(() => forgetRoom(data.id));
}
},
ready: () => {
state.wait = RETRY_MIN;
setOnline(true);
serial(afterReady);
},
error: (closed) => {
setOnline(false);
// Браузер переподключается сам, пока поток не закрыт насовсем.
if (closed) {
retryLater();
}
},
});
}
function retryLater() {
if (state.timer !== null || !state.running) {
return;
}
const delay = state.wait;
state.wait = Math.min(delay * 2, RETRY_MAX);
state.timer = setTimeout(() => {
state.timer = null;
recover();
}, delay);
}
function clearTimer() {
if (state.timer !== null) {
clearTimeout(state.timer);
state.timer = null;
}
}
// recover разбирает окончательно закрытый поток. Причин две: сессии
// больше нет — это увидит GET /api/me и уведёт на экран входа; или
// устройства больше нет — тогда его надо завести заново.
async function recover() {
if (!state.running) {
return;
}
try {
await api.me();
} catch (err) {
if (err instanceof NetworkError) {
retryLater();
}
return;
}
await connect();
}
// setOnline — состояние сети этой вкладки. Владелец потока рассказывает
// о нём соседям: своего потока у них нет (ADR-035).
function setOnline(value) {
if (state.online === value) {
return;
}
applyOnline(value);
if (state.release) {
share({ kind: "net", online: value });
}
}
function applyOnline(value) {
if (state.online === value) {
return;
}
state.online = value;
emit("net", { online: value });
}
// --- очередь работ ------------------------------------------------------
// serial выстраивает работу с базой в очередь: приём, отправка и повтор
// не должны идти одновременно.
let chain = Promise.resolve();
function serial(task) {
const next = chain.then(() => task());
chain = next.catch(() => {});
return next;
}
// schedule откладывает разбор входящих на следующий такт: очередь при
// подключении приходит событием на конверт, а записать её и подтвердить
// лучше пачкой. Разбор забирает всё, что успело накопиться.
function schedule() {
if (state.scheduled) {
return;
}
state.scheduled = true;
setTimeout(() => {
state.scheduled = false;
serial(flush);
}, 0);
}
// --- приём --------------------------------------------------------------
async function flush() {
const batch = state.inbox.splice(0, state.inbox.length);
if (batch.length === 0) {
return;
}
const messages = [];
const acked = [];
const kept = [];
for (const envelope of batch) {
if (!usable(envelope)) {
// Разобрать нечего, но и держать это в очереди сервера незачем.
if (typeof envelope?.id === "string") {
acked.push(envelope.id);
}
continue;
}
const record = await decode(envelope);
if (record === null) {
// Ключа сейчас не добыть по причине, которая пройдёт: конверт
// остаётся у нас и разбирается заново. Ждать переподключения
// нельзя — поток цел и рваться не собирается.
kept.push(envelope);
continue;
}
messages.push(record);
acked.push(record.id);
}
if (messages.length > 0) {
await db.saveMessages({ messages, me: state.nick, incoming: true });
notify(messages);
}
if (kept.length > 0) {
state.inbox.unshift(...kept);
postpone();
} else {
state.hold = RETRY_MIN;
}
// ACK — только после успешной записи (docs/storage.md).
await ackAll(acked);
}
// postpone откладывает повторный разбор: причина, по которой конверт не
// разобрался, проходит сама, но сообщать о себе не умеет. Пауза
// удваивается, удачный разбор возвращает её к минимуму.
function postpone() {
if (state.inboxTimer !== null || !state.running) {
return;
}
const delay = state.hold;
state.hold = Math.min(delay * 2, RETRY_MAX);
state.inboxTimer = setTimeout(() => {
state.inboxTimer = null;
schedule();
}, delay);
}
// usable — форма конверта (docs/protocol.md, «Типы»). Сервер её проверяет,
// но запись в базу собирается из этих полей, и мусор до неё не доходит.
function usable(e) {
return e !== null && typeof e === "object"
&& typeof e.id === "string" && validUlid(e.id)
&& typeof e.from === "string"
&& typeof e.keyId === "string"
&& typeof e.iv === "string" && typeof e.ct === "string"
&& Number.isFinite(e.ts)
&& (typeof e.to?.dm === "string") !== (typeof e.to?.room === "string");
}
// decode превращает конверт в запись messages. null означает «сейчас
// не разобрать по причине, которая пройдёт»: конверт остаётся и у нас,
// и в очереди сервера — ACK по нему не уходит. Ошибка AEAD
// и неизвестный keyId причиной не являются —
// сообщение сохраняется нерасшифрованным (docs/crypto.md, «Сообщение»)
// вместе с raw: по нему попытка повторяется, когда ключ появится
// или когда новый ключ собеседника подтвердят (docs/storage.md).
async function decode(envelope) {
const me = state.nick;
const room = typeof envelope.to.room === "string" ? envelope.to.room : null;
const peer = room === null
? (envelope.from === me ? envelope.to.dm : envelope.from)
: null;
const base = {
id: envelope.id,
chatId: room === null ? db.dmChatId(peer) : db.roomChatId(room),
from: envelope.from,
text: null,
ts: envelope.ts,
status: "sent",
};
if (room !== null) {
// Комната расшифровывается любым известным ключом по keyId конверта:
// клиент держит все ключи комнаты (ADR-018).
let key;
try {
key = await roomKeyOf(room, envelope.keyId);
} catch {
return null;
}
if (key === null) {
return { ...base, undecryptable: "unknown_key", raw: envelope };
}
try {
return { ...base, text: await openMessage(key, { ...envelope, chat: roomLabel(room) }) };
} catch {
return { ...base, undecryptable: "bad_aead", raw: envelope };
}
}
if (envelope.keyId !== DM_KEY_ID) {
return { ...base, undecryptable: "unknown_key", raw: envelope };
}
let key;
try {
key = await chatKey(peer);
} catch (err) {
if (transient(err)) {
return null;
}
// Ник исчез: публичного ключа не будет и позже, но raw остаётся.
return { ...base, undecryptable: "unknown_key", raw: envelope };
}
try {
const text = await openMessage(key, { ...envelope, chat: dmLabel(me, peer) });
return { ...base, text };
} catch {
// Расшифровка идёт доверенным ключом. Не сошлось, а у ника ждёт
// подтверждения новый, — сообщение зашифровано им (ADR-016).
const known = await db.peer(peer).catch(() => null);
return {
...base,
undecryptable: known?.pending ? "key_changed" : "bad_aead",
raw: envelope,
};
}
}
// reopen — повторная расшифровка сохранённого raw: пришёл недостающий ключ
// комнаты или подтверждён новый ключ собеседника (docs/storage.md).
// Записи свои, а не входящие: перезапись по id здесь законна (ADR-034).
async function reopen() {
let list;
try {
list = await db.undecryptable();
} catch {
return;
}
const messages = [];
for (const record of list) {
if (!usable(record.raw)) {
continue;
}
const fresh = await decode(record.raw);
if (fresh === null || fresh.text === null) {
continue;
}
messages.push(fresh);
}
if (messages.length === 0) {
return;
}
await db.saveMessages({ messages, me: state.nick });
notify(messages);
}
async function ackAll(ids) {
for (let i = 0; i < ids.length; i += api.MAX_ACK) {
try {
await api.ack(state.device, ids.slice(i, i + api.MAX_ACK));
} catch {
// Не подтвердили — сервер выдаст конверты заново, а put по тому же
// id дублей не создаст (ADR-017).
return;
}
}
}
// --- после ready --------------------------------------------------------
// afterReady — очередь выдана целиком. Клиент перечитывает контакты
// и комнаты и повторяет неотправленное (docs/ui.md, «Сеть и состояния»):
// события room и room_left в очередь не кладутся, и пропущенное во время
// офлайна восстанавливается только этим (docs/protocol.md, «События»).
async function afterReady() {
await refreshContacts();
await refreshRooms();
await payRekeys();
await retryPending();
}
async function refreshContacts() {
let list;
try {
list = await api.contacts();
} catch {
return;
}
let changed = false;
for (const contact of list) {
// Список контактов — главное место сверки TOFU: ключи всех собеседников
// приходят от сервера после каждого ready (ADR-016).
await seePeer(contact.nick, contact.publicKey, contact.createdAt).catch(() => {});
const chatId = db.dmChatId(contact.nick);
if (!(await db.chat(chatId))) {
await db.putChat(db.blankChat(chatId));
changed = true;
}
}
if (changed) {
announceChats();
}
}
// retryPending повторяет неотправленное после подключения. Идёт прямо,
// без serial: afterReady уже внутри очереди.
async function retryPending() {
for (const id of [...state.pending]) {
let record;
try {
record = await db.message(id);
} catch {
return;
}
if (!record || record.status !== "pending") {
state.pending.delete(id);
continue;
}
await attempt(record, record.id);
}
}
// --- собеседники --------------------------------------------------------
// chatKey — ключ личного чата из памяти или выведенный заново. Выводится
// он из доверенного ключа: ждущий подтверждения в дело не идёт (ADR-016).
async function chatKey(peer) {
const cached = state.keys.get(peer);
if (cached) {
return cached;
}
const record = await knownPeer(peer);
const key = await dmKey(state.privateKey, record.publicKey, state.nick, peer);
state.keys.set(peer, key);
return key;
}
// knownPeer — запись TOFU. Ника ещё нет — берём ключ у сервера: первый
// ключ запоминается молча, trust on first use (ADR-016).
async function knownPeer(nick) {
const known = await db.peer(nick);
if (known) {
return known;
}
const user = await api.user(nick);
return seePeer(user.nick, user.publicKey);
}
// seePeer — сверка TOFU. Зовётся при каждом получении публичного ключа ника,
// откуда бы он ни пришёл: GET /api/users, список контактов, состав комнаты,
// отправитель завёрнутого ключа (ADR-016).
//
// Первый ключ ника запоминается молча. Совпавший — ничего не меняет.
// Изменившийся ложится в pending: отправка этому нику блокируется, входящее
// его ключом остаётся нерасшифрованным, rekey ему не выполняется — всё
// до явного «доверять новому ключу».
//
// Сервер, вернувшийся к доверенному ключу, снимает pending: смены не
// случилось, а подтверждать было бы уже отозванный ключ (ADR-040,
// docs/ui.md, «Карточка контакта»).
async function seePeer(nick, publicKey, firstSeen = Date.now()) {
const fingerprint = await fingerprintOf(publicKey);
const known = await db.peer(nick);
if (!known) {
const record = { nick, publicKey, fingerprint, firstSeen, pending: null };
await db.putPeer(record);
return record;
}
if (known.fingerprint === fingerprint) {
if (!known.pending) {
return known;
}
const record = { ...known, pending: null };
await db.putPeer(record);
announcePeer(nick);
return record;
}
if (known.pending?.fingerprint === fingerprint) {
return known;
}
const record = { ...known, pending: { publicKey, fingerprint, seenAt: Date.now() } };
await db.putPeer(record);
announcePeer(nick);
return record;
}
// trustKey — «доверять новому ключу» из карточки контакта (docs/ui.md).
// Ключ из pending становится основным, pending чистится, и всё, что
// упиралось в старый ключ, повторяется: завёрнутые ключи комнат от этого
// ника, сохранённые raw и неотправленное.
//
// Отдаёт, было ли что подтверждать.
export function trustKey(nick) {
return serial(async () => {
if (!state.running) {
return false;
}
const known = await db.peer(nick);
if (!known?.pending) {
return false;
}
await db.putPeer({
nick: known.nick,
publicKey: known.pending.publicKey,
fingerprint: known.pending.fingerprint,
// firstSeen — когда ник встретился впервые, а не когда сменил ключ.
firstSeen: known.firstSeen,
pending: null,
});
// Ключ личного чата выводится из ключа собеседника — выводим заново.
state.keys.delete(nick);
announcePeer(nick);
await refreshRooms();
// Владелец, чей rekey упирался в этот ключ, доводит его до конца.
await payRekeys();
await reopen();
await retryPending();
return true;
});
}
// --- комнаты ------------------------------------------------------------
// TrustNeeded — rekey не выполняется участнику с изменившимся и
// неподтверждённым ключом (ADR-016). nicks — чьи ключи ждут подтверждения;
// владелец повторяет операцию после «доверять новому ключу».
export class TrustNeeded extends Error {
constructor(nicks) {
super("нужно подтвердить ключ");
this.name = "TrustNeeded";
this.nicks = nicks;
}
}
// needsTrust — чьи ключи мешают rekey комнаты (docs/ui.md, «Участники»).
export function needsTrust(roomId) {
return state.blocked.get(roomId) ?? [];
}
// usableRoom и usableKey — форма Room и завёрнутого ключа
// (docs/protocol.md, «Типы»). Сервер её держит, но записи собираются
// из этих полей, и мусор до базы не доходит.
function usableRoom(r) {
return r !== null && typeof r === "object"
&& typeof r.id === "string" && r.id !== ""
&& typeof r.name === "string"
&& typeof r.owner === "string"
&& Array.isArray(r.members) && r.members.every((nick) => typeof nick === "string")
&& Number.isFinite(r.createdAt)
&& (r.key === null || r.key === undefined || usableKey(r.key));
}
function usableKey(k) {
return k !== null && typeof k === "object"
&& typeof k.keyId === "string" && k.keyId !== ""
&& typeof k.from === "string"
&& typeof k.iv === "string" && typeof k.ct === "string";
}
// roomKeyOf — ключ комнаты по keyId конверта; null, если такого нет.
async function roomKeyOf(roomId, keyId) {
const at = `${roomId}|${keyId}`;
const cached = state.roomKeys.get(at);
if (cached) {
return cached;
}
const record = await db.roomKey(roomId, keyId);
if (!record) {
return null;
}
state.roomKeys.set(at, record.key);
return record.key;
}
// currentKeyId — текущий ключ комнаты: последний полученный этим
// устройством. Им шифруется исходящее. Порядок — получения, а не сервера:
// номера ключа протокол не несёт, и в гонке двух rekey эти порядки могут
// разойтись (ADR-042). Оба ключа при этом живы, сервер принимает любой.
async function currentKeyId(roomId) {
const list = await db.roomKeysOf(roomId);
return list.length > 0 ? list[list.length - 1].keyId : null;
}
// saveRoom кладёт комнату в список чатов. Имя, владелец и состав приходят
// от сервера; лента, счётчик непрочитанных и граница «новых» — местные.
function saveRoom(room) {
return db.mergeChat(db.roomChatId(room.id), {
type: "room",
roomId: room.id,
title: room.name,
owner: room.owner,
members: [...room.members],
// Комната в списке есть, пока мы её участники.
hidden: false,
});
}
// senderKey — публичный ключ того, кто завернул ключ комнаты. Свой берётся
// с устройства: он проверен при входе и от сервера не зависит. Чужой
// приходит от сервера и проходит через TOFU; ключ, ждущий подтверждения, —
// null: разворачивать им нельзя (ADR-016).
async function senderKey(nick) {
if (nick === state.nick) {
return state.publicKey;
}
const user = await api.user(nick);
const record = await seePeer(nick, user.publicKey);
return record.pending ? null : record.publicKey;
}
// takeRoomKey разворачивает завёрнутый нам ключ комнаты и кладёт его
// в roomKeys вместе с from и receivedAt (docs/crypto.md, «Комната»).
// Отдаёт, появился ли новый ключ.
//
// Уже известный keyId не трогается: клиент держит все ключи комнаты.
// Не развернувшийся не теряется — сервер отдаёт его снова с каждым
// GET /api/rooms.
//
// Заворачивает ключ участник комнаты — владелец или тот, кто им был
// до передачи владения (ADR-018). Ключ от постороннего ника отвергается
// до запроса его публичного ключа: TOFU запоминает первый ключ молча,
// поэтому незнакомый распространитель — это подмена, а не первый
// контакт (ADR-039).
async function takeRoomKey(room) {
const wrapped = room.key;
if (!usableKey(wrapped) || !room.members.includes(wrapped.from)) {
return false;
}
if (await db.roomKey(room.id, wrapped.keyId)) {
return false;
}
let publicKey;
try {
publicKey = await senderKey(wrapped.from);
} catch {
// Ключа отправителя сейчас не добыть: попробуем при следующем ready.
return false;
}
if (publicKey === null) {
return false;
}
let key;
try {
key = await unwrapRoomKey(state.privateKey, publicKey, {
roomId: room.id,
keyId: wrapped.keyId,
from: wrapped.from,
to: state.nick,
iv: wrapped.iv,
ct: wrapped.ct,
});
} catch {
return false;
}
const stored = await db.saveRoomKey({
roomId: room.id,
keyId: wrapped.keyId,
key,
from: wrapped.from,
});
if (stored) {
state.roomKeys.set(`${room.id}|${wrapped.keyId}`, key);
}
return stored;
}
// applyRoom разбирает событие room: создание, смена состава, rekey, выход
// участника (docs/protocol.md, «События»).
async function applyRoom(room) {
if (!state.running || !usableRoom(room)) {
return;
}
const changed = await saveRoom(room);
const fresh = await takeRoomKey(room);
if (changed) {
announceChats();
}
announceRoom(room.id);
if (fresh) {
await reopen();
await retryPending();
}
// Участник вышел — комната осталась на ключе, который он знает. Новый
// раздаёт владелец тем же запросом с пустыми add и remove (ADR-018).
if (room.needsRekey === true && room.owner === state.nick) {
await rekey(room.id);
}
}
// refreshRooms перечитывает комнаты после каждого ready: события room
// и room_left в очередь не кладутся (docs/protocol.md, «События»).
async function refreshRooms() {
// Список известных комнат читается до запроса: комната, заведённая
// соседней вкладкой, пока ответ летел, в него не попадёт, а прятать
// её нельзя — она есть и на сервере, и в базе (ADR-035).
let known;
try {
known = await db.chats();
} catch {
known = [];
}
let list;
try {
list = await api.rooms();
} catch {
return;
}
if (!Array.isArray(list)) {
return;
}
let changed = false;
let fresh = false;
const seen = new Set();
for (const room of list) {
if (!usableRoom(room)) {
continue;
}
seen.add(room.id);
const moved = await saveRoom(room);
const key = await takeRoomKey(room);
changed = changed || moved;
fresh = fresh || key;
if (moved || key) {
announceRoom(room.id);
}
// Долг по ключу — состояние комнаты, а не свойство события (ADR-041):
// владелец поднимает его и после офлайна, и после перезагрузки вкладки.
// Отдаёт долг payRekeys — он идёт следом за refreshRooms.
if (room.needsRekey === true && room.owner === state.nick) {
state.owed.add(room.id);
}
}
// Комнату, из которой нас убрали, пока мы были офлайн, видно только так:
// события мы не получили, а в списке её больше нет.
for (const chat of known) {
if (chat.type === "room" && !seen.has(chat.roomId)) {
await db.hideChat(chat.id, true);
state.blocked.delete(chat.roomId);
state.owed.delete(chat.roomId);
announceRoom(chat.roomId);
changed = true;
}
}
if (changed) {
announceChats();
}
// retryPending зовёт afterReady следом — второй раз не нужно.
if (fresh) {
await reopen();
}
}
// forgetRoom убирает комнату из списка: нас удалили, комната удалена или
// мы вышли сами. История на устройстве не трогается — она единственная
// копия, а новых сообщений в этой комнате нам уже не доставят.
async function forgetRoom(roomId) {
state.blocked.delete(roomId);
state.owed.delete(roomId);
const chatId = db.roomChatId(roomId);
const record = await db.chat(chatId);
if (!record || record.hidden) {
return;
}
await db.hideChat(chatId, true);
announceChats();
announceRoom(roomId);
}
// memberKeys — публичные ключи итогового состава с проверкой TOFU
// (ADR-016, ADR-018). Ник с неподтверждённым ключом останавливает всю
// операцию: rekey ему не выполняется, а состав без ключа невозможен.
async function memberKeys(members) {
const keys = new Map();
const blocked = [];
for (const nick of members) {
if (nick === state.nick) {
keys.set(nick, state.publicKey);
continue;
}
const user = await api.user(nick);
const record = await seePeer(nick, user.publicKey);
if (record.pending) {
blocked.push(nick);
continue;
}
keys.set(nick, record.publicKey);
}
if (blocked.length > 0) {
throw new TrustNeeded(blocked);
}
return keys;
}
// distribute генерирует ключ комнаты и заворачивает его каждому участнику,
// включая себя: заворачивание себе — ECDH(myPrivate, myPublic), тем же кодом
// (docs/crypto.md, «Комната»). Сырые байты живут до конца заворачивания,
// потом импортируются non-extractable и затираются.
async function distribute(roomId, members, keys) {
const { keyId, bytes } = newRoomKey();
try {
const wrapped = [];
for (const nick of members) {
wrapped.push(await wrapRoomKey(
state.privateKey,
keys.get(nick),
{ roomId, keyId, from: state.nick, to: nick },
bytes,
));
}
return { keyId, wrapped, key: await importRoomKey(bytes) };
} finally {
wipe(bytes);
}
}
// keepRoomKey кладёт свой же розданный ключ: у распространителя он
// не разворачивается, а берётся из сырых байт до их затирания.
async function keepRoomKey(roomId, keyId, key) {
const stored = await db.saveRoomKey({ roomId, keyId, key, from: state.nick });
if (stored) {
state.roomKeys.set(`${roomId}|${keyId}`, key);
}
return stored;
}
// createRoom заводит комнату. Идентификатор генерирует клиент: ключ
// заворачивается до запроса и привязан к roomId (ADR-037). Отдаёт chatId.
export function createRoom(name) {
return serial(async () => {
const title = String(name ?? "").trim();
if (!state.running || title === "") {
return null;
}
const keys = await memberKeys([state.nick]);
for (let attempt = 0; attempt < 3; attempt += 1) {
const roomId = newId();
const { keyId, wrapped, key } = await distribute(roomId, [state.nick], keys);
let room;
try {
room = await api.createRoom(state.device, {
id: roomId,
name: title,
keyId,
keys: wrapped,
});
} catch (err) {
if (err instanceof ApiError && err.code === "room_conflict") {
continue;
}
throw err;
}
await keepRoomKey(roomId, keyId, key);
// Форма ответа так же непроверена, как форма события. Чужой
// идентификатор в ответе означал бы ключ, привязанный не к той
// комнате: roomId вплетён в info и AAD (ADR-037).
if (usableRoom(room) && room.id === roomId) {
await saveRoom(room);
}
announceChats();
announceRoom(roomId);
return db.roomChatId(roomId);
}
throw new Error("не удалось завести комнату");
});
}
// changeMembers — смена состава и rekey одним запросом (ADR-018): владелец
// получает публичные ключи итогового состава с проверкой TOFU, генерирует
// ключ, заворачивает каждому и только потом отправляет.
//
// Ники приходят уже приведёнными к форме ADR-019: их проверяет экран.
export function changeMembers(roomId, { add = [], remove = [] } = {}) {
return serial(() => changeRoom(roomId, add, remove));
}
// rekey — новый ключ прежнему составу: тот же запрос с пустыми add
// и remove (ADR-018). Зовётся у владельца, получившего room с needsRekey.
// Неудача долг не снимает: попытка повторится после ready.
async function rekey(roomId) {
state.owed.add(roomId);
try {
await changeRoom(roomId, [], []);
} catch (err) {
if (err instanceof TrustNeeded) {
// Владелец видит, чей ключ надо подтвердить, и повторяет операцию
// после подтверждения (ADR-016).
state.blocked.set(roomId, err.nicks);
announceRoom(roomId);
return;
}
// Сеть или отказ сервера: попытка повторится после следующего ready.
}
}
// payRekeys отдаёт долги по ключам комнат. Комната, которой мы больше
// не владеем или из которой ушли, долг снимает: новый ключ раздаёт
// её владелец.
async function payRekeys() {
for (const roomId of [...state.owed]) {
let record;
try {
record = await db.chat(db.roomChatId(roomId));
} catch {
return;
}
if (!record || record.type !== "room" || record.hidden || record.owner !== state.nick) {
state.owed.delete(roomId);
if (state.blocked.delete(roomId)) {
announceRoom(roomId);
}
continue;
}
await rekey(roomId);
}
}
async function changeRoom(roomId, add, remove) {
if (!state.running) {
return null;
}
const record = await db.chat(db.roomChatId(roomId));
if (!record || record.type !== "room") {
throw new Error("нет такой комнаты");
}
const members = finalMembers(record.members ?? [], add, remove);
const keys = await memberKeys(members);
const { keyId, wrapped, key } = await distribute(roomId, members, keys);
const room = await api.changeMembers(roomId, { add, remove, keyId, keys: wrapped });
const stored = await keepRoomKey(roomId, keyId, key);
// Ключ роздан всему составу: долг закрыт, и подтверждать больше нечего.
state.owed.delete(roomId);
state.blocked.delete(roomId);
if (usableRoom(room)) {
await saveRoom(room);
}
announceChats();
announceRoom(roomId);
if (stored) {
await reopen();
await retryPending();
}
return room;
}
// finalMembers — итоговый состав: текущий без remove плюс add, без повторов
// и с сохранением порядка.
function finalMembers(current, add, remove) {
const gone = new Set(remove);
const seen = new Set();
const out = [];
for (const nick of [...current.filter((nick) => !gone.has(nick)), ...add]) {
if (seen.has(nick)) {
continue;
}
seen.add(nick);
out.push(nick);
}
return out;
}
// leaveRoom — «выйти из комнаты». Владение уходит участнику с наименьшим
// joined_at, опустевшая комната удаляется — это дело сервера (ADR-018).
export function leaveRoom(roomId) {
return serial(async () => {
await api.leaveRoom(state.device, roomId);
await forgetRoom(roomId);
});
}
// deleteRoom — «удалить комнату», только у владельца. Участникам уходит
// room_left.
export function deleteRoom(roomId) {
return serial(async () => {
await api.removeRoom(roomId);
await forgetRoom(roomId);
});
}
// --- отправка -----------------------------------------------------------
// Postponed — отправить сейчас нечем, но причина пройдёт: нет ключа комнаты
// или ключ собеседника изменился и ждёт подтверждения (ADR-016). Сообщение
// остаётся pending и уходит, когда причина уйдёт.
class Postponed extends Error {
constructor() {
super("отправка отложена");
this.name = "Postponed";
}
}
// send — новое исходящее сообщение. Пустая строка не отправляется;
// предел в maxMessageChars держит строка ввода (docs/ui.md, «Чат»).
// Отдаёт id записи или null, если отправлять нечего.
export function send(chatId, text) {
const body = String(text ?? "").trim();
const known = db.peerOf(chatId) !== null || db.roomIdOf(chatId) !== null;
if (!state.running || body === "" || !known) {
return Promise.resolve(null);
}
return serial(() => attempt({ chatId, text: body }, null));
}
// retry — повтор с пометки «не отправлено».
export function retry(id) {
if (!state.running) {
return Promise.resolve(null);
}
return serial(async () => {
const record = await db.message(id);
if (!record || record.status === "sent") {
return null;
}
return attempt(record, record.id);
});
}
// REUSE — запас под окно часов сервера: он принимает сообщение, пока время
// в ULID расходится с его часами не больше чем на пять минут (ADR-017).
// Идентификатор переиспользуется, пока до края окна остаётся минута: за неё
// успевают шифрование, очередь работ и сама сеть, так что дошедший запрос
// застаёт окно ещё открытым.
const REUSE = 4 * 60 * 1000;
// attempt — одна попытка отправки. Прежний ULID сохраняется, пока его время
// годится серверу: ответ на POST мог потеряться после того, как сервер
// сообщение принял, и повтор с тем же идентификатором получатель молча
// пропустит (ADR-034), а повтор с новым лёг бы у него вторым сообщением
// (ADR-036). Идентификатор старше запаса заменяется свежим, и тогда старая
// запись удаляется: время в id должно совпадать с временем фактической
// отправки — иначе после долгого офлайна сервер ответит clock_skew.
//
// fresh требует свежий идентификатор, каким бы годным ни выглядел прежний:
// так возвращается попытка, у которой переиспользованный id сервер отверг
// по часам.
async function attempt(source, previousId, fresh = false) {
const peer = db.peerOf(source.chatId);
const roomId = db.roomIdOf(source.chatId);
if (peer === null && roomId === null) {
state.pending.delete(previousId);
return null;
}
const keep = !fresh && previousId !== null && reusable(previousId);
const message = {
id: keep ? previousId : ulid(),
chatId: source.chatId,
from: state.nick,
text: source.text,
// Время показа идёт за идентификатором: сохранённый id оставляет
// и прежнее ts — до 202, которое принесёт серверное.
ts: keep ? source.ts : Date.now(),
status: "pending",
};
const stale = previousId !== null && !keep;
if (stale) {
state.pending.delete(previousId);
}
state.pending.add(message.id);
await db.saveMessages({
messages: [message],
remove: stale ? [previousId] : [],
me: state.nick,
});
notify([message], stale ? [{ chatId: source.chatId, id: previousId }] : []);
const err = await post(message, peer, roomId);
if (err === null) {
return message.id;
}
// Возраст переиспользованного id сервер считает по своим часам: к времени,
// проведённому в pending, добавляется расхождение часов. Отставание в пару
// минут выводит за окно идентификатор, который клиенту кажется свежим.
// Это ровно та причина, ради которой id и меняется, — берём свежий и идём
// второй раз. Второго круга нет: fresh снимает переиспользование, и такой
// же отказ на свежем id означает, что часы врут по-настоящему (ADR-036).
if (keep && err instanceof ApiError && err.code === "clock_skew") {
return attempt(message, message.id, true);
}
await settle(message, err);
return message.id;
}
// reusable — годится ли прежний идентификатор для новой попытки. Часы
// сравниваются со своими же: других у клиента нет, и первый ULID берётся
// из них же. Часы, врущие сверх окна, отсекает сервер: clock_skew на
// переиспользованном id разбирает attempt, на свежем — settle.
function reusable(id) {
const ms = ulidTime(id);
return ms !== null && Math.abs(Date.now() - ms) < REUSE;
}
// dmEnvelope — конверт личного чата. Ключ собеседника изменился и ждёт
// подтверждения — отправка блокируется (ADR-016): сообщение остаётся
// pending и уходит после «доверять новому ключу».
async function dmEnvelope(message, peer) {
const known = await db.peer(peer);
if (known?.pending) {
throw new Postponed();
}
const sealed = await sealMessage(await chatKey(peer), {
id: message.id,
chat: dmLabel(state.nick, peer),
from: state.nick,
keyId: DM_KEY_ID,
text: message.text,
});
return { id: message.id, to: { dm: peer }, keyId: DM_KEY_ID, iv: sealed.iv, ct: sealed.ct };
}
// roomEnvelope — конверт комнаты: keyId текущего ключа, chat — "room:<id>"
// (docs/crypto.md, «Сообщение»). Ключа ещё нет — отправка откладывается
// до его прихода.
async function roomEnvelope(message, roomId) {
const keyId = await currentKeyId(roomId);
const key = keyId === null ? null : await roomKeyOf(roomId, keyId);
if (key === null) {
throw new Postponed();
}
const sealed = await sealMessage(key, {
id: message.id,
chat: roomLabel(roomId),
from: state.nick,
keyId,
text: message.text,
});
return { id: message.id, to: { room: roomId }, keyId, iv: sealed.iv, ct: sealed.ct };
}
// post шифрует и отдаёт конверт серверу. from в AAD — собственный ник:
// сервер проставит то же значение из сессии, и AAD сойдётся у получателя
// (docs/crypto.md, «Сообщение»).
//
// Отдаёт null при 202 и отказ, если он был: судьбу отказа решает attempt —
// clock_skew на переиспользованном идентификаторе кончается не полосой,
// а второй попыткой.
async function post(message, peer, roomId) {
let envelope;
try {
envelope = peer !== null
? await dmEnvelope(message, peer)
: await roomEnvelope(message, roomId);
} catch (err) {
return err;
}
try {
const answer = await api.sendMessage(state.device, envelope);
// Запрос дошёл: сеть есть, что бы ни думал поток событий (ADR-028).
setOnline(true);
state.pending.delete(message.id);
const sent = { ...message, status: "sent", ts: answer?.ts ?? message.ts };
await db.saveMessages({ messages: [sent], me: state.nick });
notify([sent]);
// Сообщение ушло: обработчик решает, спрашивать ли разрешение
// на уведомления. Отправку он не задерживает и сорвать не может.
try {
sentHandler();
} catch {
// Дело обработчика; отправка состоялась.
}
return null;
} catch (err) {
// Ответ с кодом — то же доказательство, что запрос дошёл, что и 202:
// сеть есть, что бы ни думал поток событий (ADR-028). Ошибка шифрования
// сюда не попадает — она случается до запроса. 401 unauthenticated уже
// увёл на экран входа: состояние сети там ничьё.
if (err instanceof ApiError && state.running) {
setOnline(true);
}
return err;
}
}
// settle разбирает отказ. Сеть и 500 сообщение не хоронят: оно остаётся
// pending и повторится при следующем подключении (ADR-027). Удалённое
// устройство чинится тем же способом — переподключением. Остальные 4xx —
// failed с текстом отказа (ADR-033).
async function settle(message, err) {
if (err instanceof NetworkError) {
// Поток событий молчания сети не замечает: у EventSource нет
// таймаута на тишину. Не дошедший запрос — та же полоса «нет
// соединения» (docs/ui.md, «Сеть и состояния», ADR-028).
setOnline(false);
}
if (transient(err)) {
share({ kind: "pending", ids: [message.id] });
return;
}
if (err instanceof ApiError && err.code === "unknown_device") {
share({ kind: "pending", ids: [message.id] });
retryLater();
return;
}
state.pending.delete(message.id);
const failed = { ...message, status: "failed", error: api.errorText(err) };
await db.saveMessages({ messages: [failed], me: state.nick });
notify([failed]);
}
// transient — отказ, который пройдёт сам: запрос не дошёл, сервер
// не справился или шифровать пока нечем. Повтор допустим (ADR-027).
function transient(err) {
return err instanceof NetworkError
|| err instanceof Postponed
|| (err instanceof ApiError && err.status >= 500);
}
// --- действия экранов ---------------------------------------------------
// openDm заводит личный чат с ником и отдаёт chatId. Строку списка
// заводит сервер (ADR-019), публичный ключ приходит тем же ответом.
// Ошибки — 404 unknown_user и 400 self (docs/ui.md, «Новый чат»).
export async function openDm(peer) {
const answer = await api.addContact(peer);
await seePeer(answer.nick, answer.publicKey);
const chatId = db.dmChatId(answer.nick);
const existing = await db.chat(chatId);
if (!existing || existing.hidden) {
// hideChat читает и пишет одной транзакцией и заводит недостающую
// запись: приём сообщений идёт своим чередом и в неё не врезается.
await db.hideChat(chatId, false);
announceChats();
}
return chatId;
}
// forgetChat — «убрать из списка» в карточке контакта. Строка на сервере
// уходит, зеркальная у собеседника остаётся: это не блокировка (ADR-019).
// История на устройстве не трогается — чат прячется.
export async function forgetChat(chatId) {
const peer = db.peerOf(chatId);
if (peer !== null) {
await api.removeContact(peer);
}
await db.hideChat(chatId, true);
announceChats();
}
// markRead — чат прочитан. Граница «новых» и счётчик локальные, на сервер
// не уходят (docs/storage.md).
export async function markRead(chatId) {
const record = await db.markRead(chatId);
announceChats();
return record;
}
// Чтение для экранов. Писать в базу им не нужно: всё, что меняет
// состояние, живёт здесь. dmChatId, roomChatId, peerOf и roomIdOf — форма
// ключа чата (docs/storage.md): экраны собирают её из ника или
// идентификатора маршрута, а не из строки.
export {
chats,
chat,
message,
messagesBefore,
peer,
dmChatId,
roomChatId,
peerOf,
roomIdOf,
PAGE,
} from "./db.js";