Этап 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
This commit is contained in:
2026-08-23 02:57:51 +03:00
co-authored by Claude Opus 5
parent 8f67f4aa4d
commit 0c878477d2
21 changed files with 1274 additions and 53 deletions
+110
View File
@@ -438,6 +438,116 @@ export function putPeer(record) {
return put("peers", record);
}
// --- архив --------------------------------------------------------------
// allMessages и allPeers отдают хранилище целиком: архив .bare уносит всю
// историю устройства. Какие поля в него попадают, решает export.js — база
// отдаёт записи как есть (docs/storage.md, «Экспорт .bare»).
export async function allMessages() {
const db = await open();
return value(db.transaction("messages", "readonly").objectStore("messages").getAll());
}
export async function allPeers() {
const db = await open();
return value(db.transaction("peers", "readonly").objectStore("peers").getAll());
}
// mergeArchive вливает разобранный архив одной транзакцией: половина
// импорта хуже, чем ничего.
//
// Слияние идемпотентное по id сообщений и id чатов (docs/crypto.md):
// известная запись не трогается, а запись peers добавляется только для
// ника, которого в TOFU ещё нет. Своя запись всегда права — у неё есть
// состояние отправки, которого в архиве нет (ADR-050).
//
// Счётчик непрочитанных и «убрано из списка» — местные: импорт приносит
// историю, а не показания счётчиков. Место чата в списке при этом меняется:
// lastId растёт под самое новое из добавленного, и вместе с ним уезжает
// граница «новых» — пока непрочитанного у чата нет, ей нечего отчёркивать,
// а оставшись позади, она отчеркнула бы всю привезённую переписку при
// первом же входящем (ADR-050).
//
// Отдаёт число добавленных сообщений и ключи затронутых чатов.
export async function mergeArchive({ chats: list = [], messages = [], peers = [] } = {}) {
const db = await open();
const tx = db.transaction(["chats", "messages", "peers"], "readwrite");
const chatStore = tx.objectStore("chats");
const messageStore = tx.objectStore("messages");
const peerStore = tx.objectStore("peers");
// Все чтения — одним заходом до первой записи: что уже лежит в базе,
// надо знать целиком, а запросы этой же транзакции держат её живой.
const [ids, nicks, known] = await Promise.all([
value(messageStore.getAllKeys()),
value(peerStore.getAllKeys()),
value(chatStore.getAll()),
]);
const seen = new Set(ids);
const trusted = new Set(nicks);
const records = new Map(known.map((record) => [record.id, record]));
const touched = new Set();
for (const chat of list) {
if (records.has(chat.id)) {
continue;
}
// Показания устройства в архив не пишутся (docs/storage.md) — у новой
// записи они с чистого листа: место в списке считается по добавленному,
// счётчик пуст, чат в списке виден.
records.set(chat.id, {
...blankChat(chat.id),
...chat,
lastId: null,
lastReadId: null,
unread: 0,
hidden: false,
});
touched.add(chat.id);
}
let added = 0;
for (const record of messages) {
if (seen.has(record.id)) {
continue;
}
seen.add(record.id);
messageStore.put(record);
added += 1;
let chat = records.get(record.chatId);
if (!chat) {
chat = blankChat(record.chatId);
records.set(record.chatId, chat);
}
if (!chat.lastId || chat.lastId < record.id) {
chat.lastId = record.id;
}
touched.add(record.chatId);
}
for (const record of peers) {
if (trusted.has(record.nick)) {
continue;
}
trusted.add(record.nick);
peerStore.put(record);
}
for (const id of touched) {
const record = records.get(id);
// Граница «новых» едет за лентой, пока непрочитанного нет: счётчик
// и граница считаются от одной точки, иначе первое же входящее
// отчеркнёт «новыми» всю привезённую переписку. У чата с непрочитанным
// граница уже показывает на него и остаётся на месте (ADR-050).
if (record.unread === 0) {
record.lastReadId = record.lastId;
}
chatStore.put(record);
}
await done(tx);
return { added, chats: [...touched] };
}
// persist просит браузер не вычищать базу: история на устройстве —
// единственная копия (docs/storage.md).
export async function persist() {