Files
mayatnikovandClaude Opus 5 0c878477d2 Этап 5: история — экспорт и импорт .bare, устройства, место, пагинация
Экспорт: ключ архива из секрета аккаунта через HKDF, заголовок ровно 65 байт
(magic, версия, соль, 32 сырых байта отпечатка владельца, iv) и он же целиком
AAD шифротекста. Ника владельца в файле нет. Импорт сверяет отпечаток до
расшифровки, сливает идемпотентно по id, а записи peers берёт только для
ников, которых в локальном TOFU ещё нет: архивом доверие к ключу не перебить.

Настройки: устройства с датой и пометкой «это устройство», «занято N МБ»,
кнопка «экспортировать» в подтверждении выхода и в подтверждении входа
под другим ником — долг этапа 1 и обещание ADR-029 закрыты.
Лента: страницы по 50 с подгрузкой вверх без прыжка прокрутки; новая
страница вставляется, а не пересобирает ленту.

ADR-050: импорт не перезаписывает лежащую запись — у своей есть состояние
отправки, которого в архиве нет.
ADR-054: архив — недоверенный ввод. Ревью собрало архивы с ts вне диапазона
Date, мусорным lastId, ником с bidi-переопределением и roomId с обходом пути:
каждый из них навсегда ломал ленту или счётчик. Теперь форма ника, roomId,
id, ts и автора проверяется, а lastId из файла не читается вовсе.
ADR-051, 052, 053: тексты и кнопки, устройства и место, страницы ленты
без виртуализации.

Приёмка: формат сверен побайтно на модулях, скачанных с боевого сервера, —
смещения заголовка, отпечаток сырыми байтами, новые соль и iv на каждый
экспорт, подмена любого байта заголовка ломает расшифровку, чужой секрет
не открывает, семь видов битых файлов отвергнуты.

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

210 lines
6.6 KiB
JavaScript
Raw Permalink 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.
// Экран входа и регистрации — docs/ui.md, «Вход и регистрация».
import { exportHistory } from "../export.js";
import { EXPORT_FAILED, clear, confirmPanel, el, field, mark, message, setError, setNote } from "./dom.js";
const HINT = "пароль — это ключ шифрования, а не запись в базе. восстановления нет. "
+ "не короче 12 символов; лучше — фраза из нескольких слов.";
const MODES = [
["in", "вход"],
["up", "регистрация"],
];
export function renderAuth(root, ctx) {
// Состояние экрана переживает перерисовку: режим, введённый ник и ошибка.
const view = { mode: "in", nick: "", error: "" };
const paint = () => {
clear(root);
root.append(screen(ctx, view, paint));
};
paint();
}
function screen(ctx, view, paint) {
const main = el("main", "auth");
const inner = el("div", "auth__inner");
main.append(inner);
const brand = el("div", "auth__brand");
brand.append(mark(), el("span", null, "bare"));
inner.append(brand);
inner.append(tabs(view, paint));
const form = el("form", "form");
form.noValidate = true;
const register = view.mode === "up";
const nick = field("ник", { name: "nick", autocomplete: "username" });
nick.input.value = view.nick;
const password = field("пароль", {
type: "password",
name: "password",
autocomplete: register ? "new-password" : "current-password",
});
form.append(nick.wrap, password.wrap);
let invite = null;
if (register) {
form.append(el("p", "hint", HINT));
if (ctx.config?.inviteRequired) {
invite = field("инвайт-код", { name: "invite", autocomplete: "off" });
form.append(invite.wrap);
}
}
const label = register ? "регистрация" : "вход";
const submit = el("button", "button", label);
submit.type = "submit";
form.append(submit);
// На устройстве могут лежать ключи другого ника: вход под этим сотрёт
// историю прежнего, поэтому сначала подтверждение (ADR-029). История
// на устройстве — единственная копия, и подтверждение предлагает сначала
// сохранить её: секрет прежнего аккаунта ещё здесь, экспорту сессия
// не нужна (ADR-014).
const wipe = confirmPanel("", "удалить", "экспортировать");
form.append(wipe.root);
const note = message();
form.append(note);
if (view.error) {
setError(note, view.error);
}
const fail = (text) => {
view.error = text;
if (text) {
setError(note, text);
} else {
setNote(note, "");
}
};
// run — то, ради чего экран: PBKDF2 и запрос к серверу. Вызывается либо
// сразу, либо после подтверждения стирания.
const run = async () => {
submit.disabled = true;
submit.textContent = "вычисляем ключ…";
const pass = password.input.value;
try {
if (register) {
await ctx.signUp(view.nick, pass, invite ? invite.input.value.trim() : "");
} else {
await ctx.signIn(view.nick, pass);
}
ctx.go("#/");
} catch (err) {
submit.disabled = false;
submit.textContent = label;
fail(ctx.errorText(err));
}
};
const hideWipe = () => {
wipe.root.hidden = true;
submit.hidden = false;
};
// Экспорт подтверждение не закрывает: архив скачался, а входить или нет —
// отдельное решение.
wipe.extra.addEventListener("click", async () => {
if (wipe.extra.disabled) {
return;
}
wipe.extra.disabled = true;
fail("");
try {
await exportHistory();
} catch {
fail(EXPORT_FAILED);
} finally {
wipe.extra.disabled = false;
}
});
wipe.no.addEventListener("click", () => {
hideWipe();
submit.focus();
});
wipe.yes.addEventListener("click", () => {
hideWipe();
run();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (submit.disabled) {
return;
}
view.nick = nick.input.value.trim().toLowerCase();
nick.input.value = view.nick;
const pass = password.input.value;
fail("");
hideWipe();
if (!ctx.validNick(view.nick)) {
fail("ник: 232 символа, az, 09, _");
nick.input.focus();
return;
}
// Длина пароля проверяется в обоих режимах: короче минимума пароля
// нет ни у одного аккаунта, считать по нему PBKDF2 незачем (docs/ui.md).
if ([...pass].length < ctx.minPassword) {
fail(`пароль: не короче ${ctx.minPassword} символов`);
password.input.focus();
return;
}
try {
await ctx.ensureConfig();
} catch (err) {
fail(ctx.errorText(err));
return;
}
// Поле инвайта могло не появиться: конфигурация приехала только что.
if (register && ctx.config.inviteRequired && invite === null) {
view.error = "нужен инвайт-код";
paint();
return;
}
const other = await ctx.storedNick();
if (other && other !== view.nick) {
wipe.text.textContent = `на этом устройстве история @${other}. вход под другим ником удалит её.`;
wipe.root.hidden = false;
submit.hidden = true;
// Фокус — на «экспортировать»: с него безопасно начинать.
wipe.extra.focus();
return;
}
await run();
});
inner.append(form);
return main;
}
// tabs — переключатель «вход / регистрация».
function tabs(view, paint) {
const wrap = el("div", "tabs");
MODES.forEach(([mode, text], index) => {
if (index > 0) {
wrap.append(el("span", "tabs__sep", "/"));
}
const tab = el("button", "tab", text);
tab.type = "button";
const on = view.mode === mode;
if (on) {
tab.classList.add("is-on");
}
tab.setAttribute("aria-pressed", String(on));
tab.addEventListener("click", () => {
if (view.mode !== mode) {
view.mode = mode;
view.error = "";
paint();
}
});
wrap.append(tab);
});
return wrap;
}