Files
bare/web/js/db.js
T
mayatnikovandClaude Opus 5 05586218e1 Этап 3: TOFU и комнаты — ключ комнаты, атомарный rekey, участники
Сервер: комнаты, состав и завёрнутые ключи; смена состава и rekey одним
атомарным запросом с проверкой keys[].to против итогового состава; обрезка
до двух последних ключей; передача владения по joined_at; события room
каждому со своим ключом и room_left удалённым; членство и keyId в проверках
сообщения; миграция 002.

Клиент: TOFU на всех путях, по которым публичный ключ доходит до клиента;
предупреждение о смене ключа с блокировкой отправки и повторной расшифровкой
сохранённого raw; заворачивание и разворачивание ключей комнаты, включая
себе — тем же кодом, без ветвления; расшифровка любым известным keyId;
экраны участников, создание комнаты, карточка контакта с двумя отпечатками.

ADR-037: roomId генерирует клиент. crypto.md вплетает roomId в заворачивание,
а оно происходит до запроса — создатель обязан привязать ключ к идентификатору,
которого по прежнему протоколу ещё не существовало.
ADR-039: завёрнутый ключ принимается только от участника. Иначе сервер
подставляет ключ, завёрнутый посторонним аккаунтом, TOFU молчит — ник
незнакомый, первый ключ запоминается молча, — и комната уезжает на ключ
сервера. Одно подменённое поле в ответе, без сговора и подмены кода.
ADR-041: долг по rekey — состояние комнаты, а не свойство события. Владелец,
офлайн в момент выхода участника, не узнавал о долге никогда, и комната
навсегда оставалась на ключе, который вышедший знает.
ADR-038, 040, 042, 043, 044: тексты экранов комнат, снятие pending при
возврате прежнего ключа, порядок ключей, форма раньше прав, экран покинутой
комнаты.

Приёмка на боевом: комната на троих, добавленный четвёртый читает только
новое, вышедший после rekey новых не получает и писать не может,
keys_mismatch, key_exists, not_owner, owner, room_conflict, чужой X-Device.
Подмена public_key в базе даёт у собеседника предупреждение и блокирует
отправку — проверено в Chrome.

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

472 lines
18 KiB
JavaScript

// 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);
}
// 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 = () => {};
});
}