Этап 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:
+106
-19
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as api from "./api.js";
|
||||
import * as db from "./db.js";
|
||||
import * as sync from "./sync.js";
|
||||
import {
|
||||
deriveAccountKeys,
|
||||
exportPrivateJwk,
|
||||
@@ -21,8 +22,11 @@ import {
|
||||
validIterations,
|
||||
wipe,
|
||||
} from "./crypto.js";
|
||||
import { clear } from "./ui/dom.js";
|
||||
import { DESKTOP, clear, wide } from "./ui/dom.js";
|
||||
import { renderAuth } from "./ui/auth.js";
|
||||
import { renderChat } from "./ui/chat.js";
|
||||
import { renderContact } from "./ui/contact.js";
|
||||
import { renderNew } from "./ui/new.js";
|
||||
import { renderSettings } from "./ui/settings.js";
|
||||
import { frame } from "./ui/shell.js";
|
||||
|
||||
@@ -32,7 +36,7 @@ const MIN_PASSWORD = 12;
|
||||
// Ник — ADR-019. Клиент проверяет ту же форму, что и сервер.
|
||||
const NICK = /^[a-z0-9_]{2,32}$/;
|
||||
|
||||
const state = { config: null, me: null };
|
||||
const state = { config: null, me: null, dispose: null, paint: 0, shown: null };
|
||||
|
||||
// AccountError — то, что случилось с ключевым материалом, а не с сетью.
|
||||
// Сообщение уже пригодно для показа человеку (ADR-028).
|
||||
@@ -65,22 +69,83 @@ const ctx = {
|
||||
|
||||
// --- роутинг -----------------------------------------------------------
|
||||
|
||||
// render рисует экран под текущий hash. Маршруты — docs/ui.md, «Каркас»;
|
||||
// на этом этапе есть только список и настройки, остальные ведут в пустой
|
||||
// список: чатов, контактов и комнат ещё нет.
|
||||
function render() {
|
||||
// route разбирает hash. Маршруты — docs/ui.md, «Каркас»; комнаты придут
|
||||
// на этапе 3, до тех пор `#/room/…` — неизвестный путь и ведёт в список.
|
||||
const NICK_ROUTE = /^#\/(dm|contact)\/([a-z0-9_]{2,32})$/;
|
||||
|
||||
function route() {
|
||||
const hash = location.hash || "#/";
|
||||
if (hash === "#/settings") {
|
||||
return { kind: "settings" };
|
||||
}
|
||||
if (hash === "#/new") {
|
||||
return { kind: "new" };
|
||||
}
|
||||
const nick = NICK_ROUTE.exec(hash);
|
||||
if (nick) {
|
||||
return { kind: nick[1], nick: nick[2] };
|
||||
}
|
||||
return { kind: "root" };
|
||||
}
|
||||
|
||||
// render рисует экран под текущий hash. Перерисовка гасит подписки
|
||||
// прежнего экрана: список чатов и лента слушают sync.
|
||||
async function render() {
|
||||
const mine = ++state.paint;
|
||||
const app = document.getElementById("app");
|
||||
clear(app);
|
||||
if (!state.me) {
|
||||
release();
|
||||
clear(app);
|
||||
renderAuth(app, ctx);
|
||||
return;
|
||||
}
|
||||
const settings = (location.hash || "#/") === "#/settings";
|
||||
const { root, main } = frame(ctx, settings ? "screen" : "list");
|
||||
if (settings) {
|
||||
renderSettings(main, ctx);
|
||||
const where = route();
|
||||
// На десктопе `#/` показывает первый чат — тот, что вверху списка.
|
||||
let chatId = where.kind === "dm" ? sync.dmChatId(where.nick) : null;
|
||||
if (where.kind === "root" && wide()) {
|
||||
const list = await sync.chats().catch(() => []);
|
||||
if (mine !== state.paint) {
|
||||
return;
|
||||
}
|
||||
chatId = list.length > 0 ? list[0].id : null;
|
||||
}
|
||||
|
||||
release();
|
||||
clear(app);
|
||||
state.shown = chatId;
|
||||
const { root, main, dispose } = frame(ctx, where.kind === "root" ? "list" : "screen", chatId);
|
||||
// Сначала в документ, потом содержимое: экраны ставят фокус и мотают
|
||||
// ленту, а на неприсоединённом узле это не работает.
|
||||
app.append(root);
|
||||
const parts = [dispose];
|
||||
if (where.kind === "settings") {
|
||||
renderSettings(main, ctx);
|
||||
} else if (where.kind === "new") {
|
||||
renderNew(main, ctx);
|
||||
} else if (where.kind === "contact") {
|
||||
renderContact(main, ctx, where.nick);
|
||||
} else if (chatId !== null) {
|
||||
parts.push(renderChat(main, ctx, chatId));
|
||||
}
|
||||
state.dispose = () => parts.forEach((off) => off());
|
||||
}
|
||||
|
||||
function release() {
|
||||
if (state.dispose) {
|
||||
state.dispose();
|
||||
state.dispose = null;
|
||||
}
|
||||
state.shown = null;
|
||||
}
|
||||
|
||||
// Первый чат на десктопе показывается и тогда, когда список приехал позже
|
||||
// экрана: после входа на новом устройстве чаты приходят с контактами, уже
|
||||
// после первой отрисовки. Открытый чат при этом не трогаем — иначе новое
|
||||
// сообщение в соседнем чате уводило бы из текущего.
|
||||
function fill() {
|
||||
if (state.me && state.shown === null && route().kind === "root" && wide()) {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function go(hash) {
|
||||
@@ -234,6 +299,13 @@ async function adopt(nick, priv, secret) {
|
||||
accountSecret: await importSecret(secret),
|
||||
});
|
||||
state.me = { nick, publicKey, fingerprint };
|
||||
connect();
|
||||
}
|
||||
|
||||
// connect поднимает поток событий и синхронизацию. Отказы разбирает сам
|
||||
// sync: экран входа их уже не касается.
|
||||
function connect() {
|
||||
sync.start().catch(() => {});
|
||||
}
|
||||
|
||||
// raise — автоматическое повышение итераций сразу после входа, молча
|
||||
@@ -303,6 +375,7 @@ async function signOut() {
|
||||
// forget уносит историю: она на этом устройстве единственная копия
|
||||
// (docs/storage.md, docs/ui.md).
|
||||
async function forget() {
|
||||
sync.stop();
|
||||
await db.destroy();
|
||||
state.me = null;
|
||||
}
|
||||
@@ -318,6 +391,25 @@ function errorText(err) {
|
||||
|
||||
async function boot() {
|
||||
db.persist();
|
||||
// Обработчик ставится раньше первого запроса: 401 unauthenticated
|
||||
// на любом из них — на экран входа, IndexedDB цела.
|
||||
api.onSessionExpired(() => {
|
||||
if (state.me) {
|
||||
sync.stop();
|
||||
state.me = null;
|
||||
render();
|
||||
}
|
||||
});
|
||||
addEventListener("hashchange", render);
|
||||
// Перелом ширины меняет только выбор маршрута: на десктопе `#/` — первый
|
||||
// чат, на мобильном — список. Открытый экран не трогаем: в нём набранный
|
||||
// текст, а всё остальное разбирает CSS.
|
||||
matchMedia(DESKTOP).addEventListener("change", () => {
|
||||
if (route().kind === "root") {
|
||||
render();
|
||||
}
|
||||
});
|
||||
sync.on("chats", fill);
|
||||
try {
|
||||
await ensureConfig();
|
||||
} catch {
|
||||
@@ -325,14 +417,9 @@ async function boot() {
|
||||
}
|
||||
state.me = await restore();
|
||||
render();
|
||||
addEventListener("hashchange", render);
|
||||
// 401 unauthenticated на любом запросе — на экран входа, IndexedDB цела.
|
||||
api.onSessionExpired(() => {
|
||||
if (state.me) {
|
||||
state.me = null;
|
||||
render();
|
||||
}
|
||||
});
|
||||
if (state.me) {
|
||||
connect();
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
Reference in New Issue
Block a user