Files
bare/web/js/main.js
T
mayatnikovandClaude Opus 5 f9ac83bef8 Развести номера ADR: 064/065/066 этой ветки → 073/074/075
Ветка отведена от 522ba89, где последним был ADR-063, и заняла
064–072. Тем временем в main через #3 и #4 пришли свои 064
(строка ввода — div вместо form), 065 (стороны сообщений)
и 066 (серверного перца нет).

Переезжают три ADR этой ветки, 067–072 остаются на месте:
их номера свободны, а внутренние ссылки менять незачем.

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

535 lines
21 KiB
JavaScript

// Загрузка, роутинг по hash и состояние аккаунта.
//
// Экраны из js/ui/ занимаются только разметкой: всё, что меняет состояние —
// вход, регистрация, смена пароля, выход, удаление аккаунта — живёт здесь
// и отдаётся экранам через ctx.
import * as api from "./api.js";
import { NetworkError } from "./api.js";
import * as db from "./db.js";
import * as pwa from "./pwa.js";
import * as sync from "./sync.js";
import {
deriveAccountKeys,
exportPrivateJwk,
exportPublicJwk,
fingerprintOf,
generateIdentity,
importPrivate,
importSecret,
openBlob,
parseBlob,
publicJwk,
sealBlob,
validIterations,
wipe,
} from "./crypto.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 { renderMembers } from "./ui/members.js";
import { renderNew } from "./ui/new.js";
import { renderSettings } from "./ui/settings.js";
import { frame } from "./ui/shell.js";
import { lock } from "./zoom.js";
// Минимальная длина пароля — ADR-013.
const MIN_PASSWORD = 12;
// Ник — ADR-019. Клиент проверяет ту же форму, что и сервер.
const NICK = /^[a-z0-9_]{2,32}$/;
const state = { config: null, me: null, dispose: null, paint: 0, shown: null };
// AccountError — то, что случилось с ключевым материалом, а не с сетью.
// Сообщение уже пригодно для показа человеку (ADR-028).
class AccountError extends Error {
constructor(text) {
super(text);
this.name = "AccountError";
}
}
const ctx = {
minPassword: MIN_PASSWORD,
validNick: (nick) => NICK.test(nick),
storedNick,
get config() {
return state.config;
},
get me() {
return state.me;
},
errorText,
ensureConfig,
devices,
removeDevice,
signUp,
signIn,
changePassword,
deleteAccount,
signOut,
go,
};
// --- роутинг -----------------------------------------------------------
// route разбирает hash. Маршруты — docs/ui.md, «Каркас». Ник — форма
// ADR-019, идентификатор комнаты — 16 случайных байт base64url
// (docs/crypto.md, «Идентификаторы»). Всё, что не разобралось, — список.
const NICK_ROUTE = /^#\/(dm|contact)\/([a-z0-9_]{2,32})$/;
const ROOM_ROUTE = /^#\/room\/([A-Za-z0-9_-]{22})(\/members)?$/;
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] };
}
const room = ROOM_ROUTE.exec(hash);
if (room) {
return { kind: room[2] ? "members" : "room", roomId: room[1] };
}
return { kind: "root" };
}
// render рисует экран под текущий hash. Перерисовка гасит подписки
// прежнего экрана: список чатов и лента слушают sync.
async function render() {
const mine = ++state.paint;
const app = document.getElementById("app");
if (!state.me) {
release();
clear(app);
renderAuth(app, ctx);
return;
}
const where = route();
// На десктопе `#/` показывает первый чат — тот, что вверху списка.
// У экранов «карточка контакта» и «участники» открытого чата нет:
// в сайдбаре не выделен никто.
let chatId = null;
if (where.kind === "dm") {
chatId = sync.dmChatId(where.nick);
} else if (where.kind === "room") {
chatId = sync.roomChatId(where.roomId);
}
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") {
parts.push(renderContact(main, ctx, where.nick));
} else if (where.kind === "members") {
parts.push(renderMembers(main, ctx, where.roomId));
} 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) {
if (location.hash === hash) {
render();
} else {
location.hash = hash;
}
}
// --- состояние ---------------------------------------------------------
async function ensureConfig() {
if (!state.config) {
state.config = await api.config();
}
return state.config;
}
// restore отвечает на вопрос «вошли ли мы»: сессия у сервера и ключи
// на устройстве нужны вместе. Ключей нет — нужен вход, он их и вернёт.
//
// Запрос, который не дошёл, — это «нет соединения», а не «мы не вошли»
// (docs/ui.md, «Сеть и состояния»): офлайн-старт установленного
// приложения поднимается из кэша с ключами и историей устройства,
// а полосу «нет соединения» рисует sync. Если сессии и правда нет,
// первый дошедший запрос ответит 401 unauthenticated и уведёт на вход.
async function restore() {
let who = null;
try {
who = await api.me();
} catch (err) {
if (!(err instanceof NetworkError)) {
return null;
}
}
let meta;
try {
meta = await db.meta(["nick", "publicKey", "fingerprint", "privateKey"]);
} catch {
return null;
}
if (!meta.privateKey || !meta.nick) {
return null;
}
if (who !== null && meta.nick !== who.nick) {
return null;
}
return { nick: meta.nick, publicKey: meta.publicKey, fingerprint: meta.fingerprint };
}
// storedNick — чьи ключи лежат на устройстве. Вход под другим ником стирает
// базу, поэтому экран входа сначала спрашивает (ADR-029).
async function storedNick() {
try {
const meta = await db.meta(["nick"]);
return meta.nick ?? null;
} catch {
return null;
}
}
// --- устройства ---------------------------------------------------------
// devices — устройства аккаунта для настроек (docs/protocol.md,
// «Устройства»). Своё сервер помечает по сессии; заодно сверяем
// с устройством этой вкладки: сессия привязывается к устройству
// в POST /api/devices, и до него current не проставлен (ADR-017).
async function devices() {
const list = await api.devices();
const mine = sync.deviceId();
if (!Array.isArray(list)) {
return [];
}
return list
.filter((item) => item !== null && typeof item === "object" && typeof item.id === "string")
.map((item) => ({
id: item.id,
createdAt: item.createdAt,
current: item.current === true || (mine !== null && item.id === mine),
}));
}
// removeDevice — «удалить» в настройках: очередь, подписка и сессии
// устройства уходят вместе с ним. Своё устройство сюда не приходит —
// его отцепляет «выйти» (ADR-052).
function removeDevice(id) {
return api.removeDevice(id);
}
// --- аккаунт -----------------------------------------------------------
// derive — вывод ключей по числу итераций, пришедшему от сервера. Границы
// проверяются до PBKDF2: authKey уходит на сервер сразу после вычисления,
// а слишком большой iter не заканчивается вовсе (ADR-030).
async function derive(nick, password, iterations) {
if (!validIterations(iterations)) {
throw new AccountError("параметры ключа не совпали");
}
return deriveAccountKeys(nick, password, iterations);
}
async function signUp(nick, password, invite) {
const config = await ensureConfig();
const iterations = config.kdfIterations;
const { authKey, kek } = await derive(nick, password, iterations);
const { keyPair, secret } = await generateIdentity();
try {
const priv = await exportPrivateJwk(keyPair.privateKey);
const body = {
nick,
authKey,
publicKey: await exportPublicJwk(keyPair.publicKey),
blob: await sealBlob({ nick, iterations, kek, priv, secret }),
};
if (invite) {
body.invite = invite;
}
await api.register(body);
await adopt(nick, priv, secret);
} finally {
wipe(secret);
}
}
async function signIn(nick, password) {
const config = await ensureConfig();
const { authKey, iterations, priv, secret } = await unlock(nick, password);
try {
await adopt(nick, priv, secret);
if (iterations < config.kdfIterations) {
await raise(nick, password, authKey, priv, secret, config.kdfIterations);
}
} finally {
wipe(secret);
}
}
// unlock скачивает ключевой блоб и расшифровывает его. Блоб отдаёт только
// вход — другого источника в протоколе нет, поэтому смена пароля тоже
// проходит через login (docs/crypto.md, «Повышение итераций и смена пароля»).
async function unlock(nick, password) {
const { iterations } = await api.kdf(nick);
const { authKey, kek } = await derive(nick, password, iterations);
const account = await api.login(nick, authKey);
let blob;
try {
blob = parseBlob(account.blob);
} catch {
throw new AccountError("ключ аккаунта повреждён");
}
// Расшифровка идёт по iter из блоба; расхождение с ответом /api/kdf
// означает несогласованность данных (docs/crypto.md, «Ключевой блоб»).
if (blob.iter !== iterations) {
throw new AccountError("параметры ключа не совпали");
}
let opened;
try {
opened = await openBlob(blob, kek, nick);
} catch {
throw new AccountError("ключ аккаунта повреждён");
}
// Публичный ключ, который сервер раздаёт собеседникам, обязан
// соответствовать приватному из блоба.
if (opened.priv.x !== account.publicKey?.x || opened.priv.y !== account.publicKey?.y) {
wipe(opened.secret);
throw new AccountError("ключ аккаунта повреждён");
}
return { authKey, iterations, priv: opened.priv, secret: opened.secret };
}
// adopt кладёт ключи на устройство. Сырые байты дальше не идут:
// в IndexedDB попадают только non-extractable CryptoKey (docs/crypto.md).
async function adopt(nick, priv, secret) {
const previous = await db.meta(["nick"]);
if (previous.nick && previous.nick !== nick) {
// На устройстве история другого аккаунта: один аккаунт на браузерный
// профиль (docs/storage.md). Согласие на стирание спрашивает экран
// входа до вычисления ключа (ADR-029) — здесь оно уже получено.
await db.destroy();
}
const publicKey = publicJwk(priv);
const fingerprint = await fingerprintOf(publicKey);
await db.putMeta({
nick,
publicKey,
fingerprint,
privateKey: await importPrivate(priv),
accountSecret: await importSecret(secret),
});
state.me = { nick, publicKey, fingerprint };
connect();
}
// connect поднимает поток событий и синхронизацию. Отказы разбирает сам
// sync: экран входа их уже не касается.
//
// Подписка на пуши переставляется на текущее устройство сразу после
// того, как оно завелось: она живёт в браузерном профиле и про смену
// deviceId сама не узнаёт (ADR-046).
function connect() {
sync.start().then(() => pwa.refresh(state.config?.vapidPublicKey)).catch(() => {});
}
// raise — автоматическое повышение итераций сразу после входа, молча
// и без завершения чужих сессий (docs/crypto.md).
async function raise(nick, password, authKey, priv, secret, iterations) {
try {
const next = await derive(nick, password, iterations);
await api.password({
authKey,
newAuthKey: next.authKey,
blob: await sealBlob({ nick, iterations, kek: next.kek, priv, secret }),
logoutOthers: false,
});
} catch {
// Не вышло — вход уже состоялся, повторим при следующем.
}
}
async function changePassword(current, next, logoutOthers) {
const config = await ensureConfig();
const nick = state.me.nick;
// unlock входит заново — иначе не добыть блоб, — а вход заводит новую
// сессию и перезаписывает cookie. Прежнюю закрываем сами и до входа:
// иначе её строка осталась бы жить на сервере, а токена от неё нет уже
// ни у кого. Сорвавшийся unlock после этого оставляет клиент без сессии,
// но не на экране входа: следующая попытка начинается с того же
// служебного выхода и проходит целиком (ADR-031).
await api.dropSession();
const { authKey, priv, secret } = await unlock(nick, current);
try {
const iterations = config.kdfIterations;
const derived = await derive(nick, next, iterations);
await api.password({
authKey,
newAuthKey: derived.authKey,
blob: await sealBlob({ nick, iterations, kek: derived.kek, priv, secret }),
logoutOthers,
});
} finally {
wipe(secret);
}
// Вход завёл новую сессию, а сессия заводится без устройства: привязку
// делает POST /api/devices. Без неё удаление этого устройства с другого
// не завершит здешнюю сессию (docs/protocol.md, «Устройства»).
await sync.rebindDevice();
}
// deleteAccount входит заново тем же порядком, что и смена пароля: сессии
// может уже не быть — её закрывает сорвавшаяся смена пароля, — а DELETE
// /api/me без сессии не проходит. Блоб здесь не нужен, поэтому вход прямой,
// без unlock: аккаунт с испорченным блобом обязан удаляться (ADR-031).
async function deleteAccount(password) {
const nick = state.me.nick;
const { iterations } = await api.kdf(nick);
const { authKey } = await derive(nick, password, iterations);
await api.dropSession();
await api.login(nick, authKey);
await api.deleteMe(authKey);
await forget();
}
async function signOut() {
// Подписка снимается на сервере, пока сессия ещё жива: устройство
// остаётся у аккаунта, и живая строка в базе слала бы пуши прежнего
// аккаунта человеку, который вошёл на этом устройстве под другим
// (ADR-046).
await dropPush();
try {
await api.dropSession();
} catch {
// Сети нет или сервер не справился; база стирается в любом случае.
}
await forget();
}
// dropPush снимает подписку у сервера. Отказ ничего не меняет: 401
// означает, что сессии и так нет, а всё прочее чинится отпиской
// у push-сервиса и правилом 404/410 (ADR-011).
async function dropPush() {
const device = sync.deviceId();
if (!device) {
return;
}
try {
await api.clearPush(device);
} catch {
// Не сняли — снимет push-сервис и правило мёртвых подписок.
}
}
// forget уносит историю: она на этом устройстве единственная копия
// (docs/storage.md, docs/ui.md). Подписка на пуши уходит вместе с ней:
// она принадлежала устройству этого аккаунта (ADR-046).
async function forget() {
sync.stop();
// Подписка снимается своим ходом: выход ждёт стирания истории,
// а не push-сервиса.
pwa.detach().catch(() => {});
await db.destroy();
state.me = null;
}
function errorText(err) {
if (err instanceof AccountError) {
return err.message;
}
return api.errorText(err);
}
// --- старт -------------------------------------------------------------
async function boot() {
// Масштабирования нет: интерфейс ведёт себя как приложение, а не как
// страница (ADR-075). Строке viewport Safari верит не всегда, поэтому
// страховка ставится до первого экрана.
lock();
db.persist();
// Service worker ставится с первой секунды: кэш оболочки нужен и до
// входа, а пуши приходят в него же (ADR-023). Отказ ничего не ломает.
pwa.register();
// Новая версия оболочки включается сама и перезагружает страницу:
// в установленном на «Домой» приложении перезагрузить её нечем
// (ADR-068).
pwa.watch();
// Первое успешно отправленное сообщение за всю историю устройства —
// единственный повод спросить разрешение на уведомления (ADR-011);
// «один раз» считает pwa.js.
sync.onSent(() => {
pwa.askOnce(state.config?.vapidPublicKey).catch(() => {});
});
// Обработчик ставится раньше первого запроса: 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 {
// Ошибку покажем на первой попытке входа: экран рисуется и без конфигурации.
}
state.me = await restore();
render();
if (state.me) {
connect();
}
}
boot();