Files
bare/web/js/db.js
T
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

582 lines
23 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.
// IndexedDB клиента — схема из docs/storage.md, «Клиент — IndexedDB».
//
// База `bare`, версия 1, все хранилища заводятся сразу: одна версия — одна
// схема, даже если часть хранилищ наполняется на следующих этапах.
// CryptoKey кладётся объектом: structured clone умеет их хранить,
// и приватный ключ остаётся non-extractable.
const NAME = "bare";
const VERSION = 1;
let opening = null;
export function open() {
if (!opening) {
opening = new Promise((resolve, reject) => {
const request = indexedDB.open(NAME, VERSION);
request.onupgradeneeded = () => create(request.result);
request.onsuccess = () => {
const db = request.result;
// Соседняя вкладка стирает базу при выходе из аккаунта — отпускаем
// соединение, иначе удаление зависнет заблокированным.
db.onversionchange = () => {
db.close();
opening = null;
};
resolve(db);
};
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error("база занята другой вкладкой"));
});
opening.catch(() => {
opening = null;
});
}
return opening;
}
function create(db) {
db.createObjectStore("meta"); // ключ — строка снаружи значения
db.createObjectStore("chats", { keyPath: "id" });
const messages = db.createObjectStore("messages", { keyPath: "id" });
messages.createIndex("chat", ["chatId", "id"]);
db.createObjectStore("roomKeys", { keyPath: ["roomId", "keyId"] });
db.createObjectStore("peers", { keyPath: "nick" });
}
function done(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error("транзакция отменена"));
});
}
function value(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// get и put — одна запись одного хранилища. Ключ у chats, messages
// и peers лежит внутри значения (keyPath), поэтому put берёт запись целиком.
async function get(name, key) {
const db = await open();
return value(db.transaction(name, "readonly").objectStore(name).get(key));
}
async function put(name, record) {
const db = await open();
const tx = db.transaction(name, "readwrite");
tx.objectStore(name).put(record);
await done(tx);
}
// meta читает несколько ключей одной транзакцией.
export async function meta(keys) {
const db = await open();
const store = db.transaction("meta", "readonly").objectStore("meta");
const out = {};
await Promise.all(keys.map(async (key) => {
out[key] = await value(store.get(key));
}));
return out;
}
// putMeta пишет пары «ключ — значение» одной транзакцией.
export async function putMeta(entries) {
const db = await open();
const tx = db.transaction("meta", "readwrite");
const store = tx.objectStore("meta");
for (const [key, item] of Object.entries(entries)) {
store.put(item, key);
}
await done(tx);
}
// --- чаты ---------------------------------------------------------------
// PAGE — страница ленты: 50 сообщений (docs/storage.md).
export const PAGE = 50;
const DM = "dm:";
const ROOM = "room:";
// Ключ чата — "dm:<собеседник>" или "room:<roomId>" (docs/storage.md).
// Это не метка чата в AAD сообщения: там у личного чата оба ника.
export function dmChatId(peer) {
return DM + peer;
}
export function roomChatId(roomId) {
return ROOM + roomId;
}
// peerOf — с кем личный чат; у комнаты собеседника нет.
export function peerOf(chatId) {
return chatId.startsWith(DM) ? chatId.slice(DM.length) : null;
}
// roomIdOf — какая комната; у личного чата комнаты нет.
export function roomIdOf(chatId) {
return chatId.startsWith(ROOM) ? chatId.slice(ROOM.length) : null;
}
// blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»:
// сигил ставит экран. Комнате имя, владелец и состав приходят из
// GET /api/rooms и события room; до этого вместо имени стоит идентификатор.
export function blankChat(id) {
const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false };
const peer = peerOf(id);
if (peer !== null) {
return { ...base, type: "dm", title: peer, peer };
}
const roomId = id.slice(ROOM.length);
return { ...base, type: "room", title: roomId, roomId };
}
// chats — список чатов в порядке docs/ui.md: по lastId по убыванию.
// Скрытые («убрать из списка») не отдаются, пока их не попросят.
export async function chats({ hidden = false } = {}) {
const db = await open();
const store = db.transaction("chats", "readonly").objectStore("chats");
const list = await value(store.getAll());
return list.filter((c) => hidden || !c.hidden).sort(byLastId);
}
function byLastId(a, b) {
if (a.lastId !== b.lastId) {
if (!a.lastId) {
return 1;
}
if (!b.lastId) {
return -1;
}
return a.lastId < b.lastId ? 1 : -1;
}
return a.id < b.id ? -1 : 1;
}
export function chat(id) {
return get("chats", id);
}
export function putChat(record) {
return put("chats", record);
}
// mergeChat правит поля чата, не трогая ленту и счётчики: чтение и запись
// одной транзакцией, чтобы не разъехаться с параллельным markRead.
// Недостающая запись заводится. Отдаёт, изменилось ли что-нибудь.
export async function mergeChat(id, patch) {
const db = await open();
const tx = db.transaction("chats", "readwrite");
const store = tx.objectStore("chats");
const known = await value(store.get(id));
const record = known ?? blankChat(id);
// Новую запись пишем всегда: она могла совпасть с пустой по всем полям
// патча и осталась бы ненаписанной.
let changed = known === undefined;
for (const [key, next] of Object.entries(patch)) {
if (same(record[key], next)) {
continue;
}
record[key] = next;
changed = true;
}
if (changed) {
store.put(record);
}
await done(tx);
return changed;
}
function same(a, b) {
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((item, i) => item === b[i]);
}
return a === b;
}
// markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает
// к последнему сообщению. Обе величины локальные, на сервер не уходят
// (docs/storage.md).
export async function markRead(chatId) {
const db = await open();
const tx = db.transaction("chats", "readwrite");
const store = tx.objectStore("chats");
const record = await value(store.get(chatId));
if (record) {
record.unread = 0;
record.lastReadId = record.lastId;
store.put(record);
}
await done(tx);
return record ?? null;
}
// hideChat прячет чат из списка или возвращает его туда. История
// не трогается: «убрать из списка» — не удаление (ADR-019).
export async function hideChat(chatId, hidden) {
const db = await open();
const tx = db.transaction("chats", "readwrite");
const store = tx.objectStore("chats");
const record = (await value(store.get(chatId))) ?? blankChat(chatId);
record.hidden = hidden;
store.put(record);
await done(tx);
return record;
}
// --- сообщения ----------------------------------------------------------
export function message(id) {
return get("messages", id);
}
// saveMessages пишет сообщения и обновляет их чаты одной транзакцией.
// ACK серверу уходит только после успешной записи (docs/storage.md),
// поэтому лента и счётчик непрочитанных не должны расходиться.
//
// remove — идентификаторы, которые надо убрать: устаревший ULID
// неотправленного сообщения меняется на свежий, и старая запись уходит
// (ADR-036).
// me — собственный ник: свои сообщения непрочитанными не считаются.
// incoming — сообщения пришли из потока событий: известный id
// игнорируется целиком, перезаписи нет (ADR-034).
//
// Отдаёт ключи затронутых чатов.
export async function saveMessages({
messages = [],
remove = [],
me = null,
incoming = false,
} = {}) {
if (messages.length === 0 && remove.length === 0) {
return [];
}
const db = await open();
const tx = db.transaction(["messages", "chats"], "readwrite");
const store = tx.objectStore("messages");
const chatStore = tx.objectStore("chats");
for (const id of remove) {
store.delete(id);
}
// Оба чтения — запросы этой же транзакции: она живёт, пока их ждут.
const known = await Promise.all(messages.map((m) => value(store.get(m.id))));
const ids = [...new Set(messages.map((m) => m.chatId))];
const records = await Promise.all(ids.map((id) => value(chatStore.get(id))));
const touched = new Map();
ids.forEach((id, i) => touched.set(id, records[i] ?? blankChat(id)));
// Повтор доставки не должен ни дублировать ленту, ни двигать счётчик:
// сервер выдаёт очередь заново при каждом подключении и вправе
// прислать конверт дважды в одной пачке (ADR-017). Дубли внутри пачки
// видны только здесь: known собран до первого put.
const seen = new Set();
messages.forEach((m, i) => {
const twice = seen.has(m.id);
seen.add(m.id);
// Входящее с уже известным id игнорируется целиком: id открыт
// в конверте, и перезапись отдала бы собеседнику чужую запись
// в истории (ADR-034). Исходящее по своему id пишется всегда —
// это переход pending → sent/failed.
if (twice || (incoming && known[i] !== undefined)) {
return;
}
store.put(m);
const record = touched.get(m.chatId);
if (!record.lastId || record.lastId < m.id) {
record.lastId = m.id;
}
if (known[i] !== undefined) {
return;
}
if (m.from !== me && (!record.lastReadId || record.lastReadId < m.id)) {
record.unread += 1;
}
// Новое сообщение возвращает скрытый чат в список.
record.hidden = false;
});
for (const record of touched.values()) {
chatStore.put(record);
}
await done(tx);
return [...touched.keys()];
}
// messagesBefore — страница ленты назад от before, не включая его,
// по индексу "chat" (docs/storage.md). Отдаёт по возрастанию id.
export async function messagesBefore(chatId, before = null, limit = PAGE) {
const db = await open();
const store = db.transaction("messages", "readonly").objectStore("messages");
// Ключ индекса — [chatId, id]. Массив больше любой строки, поэтому
// [chatId, []] — верхняя граница всех сообщений чата, а [chatId] —
// нижняя: короткий массив идёт раньше своих продолжений.
const range = before
? IDBKeyRange.bound([chatId], [chatId, before], false, true)
: IDBKeyRange.bound([chatId], [chatId, []]);
const out = [];
await cursor(store.index("chat").openCursor(range, "prev"), (record) => {
out.push(record);
return out.length < limit;
});
out.reverse();
return out;
}
// pendingMessages — неотправленное по возрастанию id. Индекса по статусу
// в схеме нет (docs/storage.md), поэтому это проход курсором: он делается
// один раз при старте, дальше отправитель ведёт свой список.
export async function pendingMessages() {
const db = await open();
const store = db.transaction("messages", "readonly").objectStore("messages");
const out = [];
await cursor(store.openCursor(), (record) => {
if (record.status === "pending") {
out.push(record);
}
return true;
});
return out;
}
// undecryptable — всё, что не удалось расшифровать и что хранит raw для
// повторной попытки (docs/storage.md). Индекса по этому в схеме нет, значит
// проход курсором; попытка повторяется редко: при подтверждении ключа
// и при появлении недостающего ключа комнаты.
export async function undecryptable() {
const db = await open();
const store = db.transaction("messages", "readonly").objectStore("messages");
const out = [];
await cursor(store.openCursor(), (record) => {
if (record.undecryptable && record.raw) {
out.push(record);
}
return true;
});
return out;
}
// cursor обходит курсор, пока step не скажет «хватит».
function cursor(request, step) {
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const current = request.result;
if (!current || !step(current.value)) {
resolve();
return;
}
current.continue();
};
request.onerror = () => reject(request.error);
});
}
// --- ключи комнат -------------------------------------------------------
// Ключ хранилища roomKeys — [roomId, keyId]; массив больше любой строки,
// поэтому [roomId, []] — верхняя граница всех ключей комнаты, а [roomId] —
// нижняя (тот же приём, что и в индексе "chat").
function roomRange(roomId) {
return IDBKeyRange.bound([roomId], [roomId, []]);
}
export function roomKey(roomId, keyId) {
return get("roomKeys", [roomId, keyId]);
}
// roomKeysOf — все ключи комнаты по возрастанию receivedAt: последний
// и есть текущий (docs/storage.md, ADR-042).
export async function roomKeysOf(roomId) {
const db = await open();
const store = db.transaction("roomKeys", "readonly").objectStore("roomKeys");
const list = await value(store.getAll(roomRange(roomId)));
return list.sort((a, b) => a.receivedAt - b.receivedAt);
}
// saveRoomKey кладёт ключ комнаты, если такого ещё нет; отдаёт, случилось ли
// это. Клиент держит все ключи комнаты и расшифровывает любым известным
// (ADR-018), поэтому уже сохранённый ключ не перезаписывается.
//
// receivedAt строго больше времени всех прежних ключей комнаты: текущий ключ
// — последний полученный этим устройством, а два rekey подряд укладываются
// в одну миллисекунду (ADR-042, docs/storage.md).
export async function saveRoomKey({ roomId, keyId, key, from }) {
const db = await open();
const tx = db.transaction("roomKeys", "readwrite");
const store = tx.objectStore("roomKeys");
const list = await value(store.getAll(roomRange(roomId)));
let receivedAt = Date.now();
let known = false;
for (const record of list) {
known = known || record.keyId === keyId;
if (record.receivedAt >= receivedAt) {
receivedAt = record.receivedAt + 1;
}
}
if (!known) {
store.put({ roomId, keyId, key, from, receivedAt });
}
await done(tx);
return !known;
}
// --- собеседники --------------------------------------------------------
// peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом
// получении ключа; изменившийся ключ ложится в pending и ждёт подтверждения.
export function peer(nick) {
return get("peers", nick);
}
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() {
if (!navigator.storage?.persist) {
return false;
}
try {
return await navigator.storage.persist();
} catch {
return false;
}
}
// destroy стирает базу целиком: выход из аккаунта уносит историю
// (docs/storage.md, один аккаунт на браузерный профиль).
export async function destroy() {
const db = await open().catch(() => null);
if (db) {
db.close();
}
opening = null;
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
// Блокировка — не ответ: соседние вкладки закрывают соединение
// по versionchange, после чего удаление доходит до success.
// Сказать «история удалена», не удалив её, нельзя.
request.onblocked = () => {};
});
}