Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
8.6 KiB
Хранение
Сервер — SQLite
Режим: journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout=5000. Версия схемы — PRAGMA user_version; миграции — internal/store/migrations/NNN_*.sql, встроены через embed, применяются по порядку при старте, каждая в транзакции. Время — миллисекунды Unix в INTEGER.
Миграция 001
CREATE TABLE users (
nick TEXT PRIMARY KEY,
auth_hash BLOB NOT NULL, -- argon2id(authKey), 32 байта
auth_salt BLOB NOT NULL, -- 16 байт
auth_params TEXT NOT NULL, -- "argon2id,m=19456,t=2,p=1"
public_key TEXT NOT NULL, -- JWK, JSON
key_blob TEXT NOT NULL, -- непрозрачный JSON клиента
created_at INTEGER NOT NULL
);
CREATE TABLE devices (
id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
push_subscription TEXT, -- JSON PushSubscription или NULL
push_pending INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX devices_nick ON devices(nick);
CREATE TABLE sessions (
token_hash BLOB PRIMARY KEY, -- SHA-256(токен)
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
device_id TEXT REFERENCES devices(id) ON DELETE CASCADE, -- NULL до POST /api/devices
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX sessions_nick ON sessions(nick);
CREATE TABLE contacts (
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
peer TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (nick, peer)
);
CREATE TABLE rooms (
id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт сервер
name TEXT NOT NULL,
owner TEXT NOT NULL REFERENCES users(nick),
created_at INTEGER NOT NULL
);
CREATE TABLE room_members (
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
joined_at INTEGER NOT NULL,
PRIMARY KEY (room_id, nick)
);
CREATE INDEX room_members_nick ON room_members(nick);
CREATE TABLE room_keys (
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
key_id TEXT NOT NULL,
sender TEXT NOT NULL, -- кто завернул
iv TEXT NOT NULL,
ct TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (room_id, nick, key_id)
);
CREATE TABLE queue (
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
msg_id TEXT NOT NULL,
envelope TEXT NOT NULL, -- готовый JSON Envelope
created_at INTEGER NOT NULL,
PRIMARY KEY (device_id, msg_id)
);
CREATE INDEX queue_created ON queue(created_at);
Текущий ключ комнаты для участника — строка room_keys с максимальным created_at; keyId считается ключом комнаты, если есть хоть одна строка с таким key_id для room_id.
Удаление пользователя: перед DELETE FROM users сервер обрабатывает комнаты, где он владелец (передача или удаление), остальное — каскад.
Фоновая чистка, раз в час
DELETE FROM queue WHERE created_at < :now - 30 дней;
DELETE FROM devices WHERE last_seen < :now - 90 дней;
DELETE FROM sessions WHERE expires_at < :now;
-- room_keys: оставить два последних key_id на комнату
Чего в базе нет
Истории сообщений, плейнтекста, паролей, ключей в открытом виде, IP-адресов, логов доставки.
Клиент — IndexedDB
База bare, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). Вход под другим ником стирает её так же и тоже после подтверждения — на экране входа (ADR-029).
meta key: string → value
deviceId, nick, publicKey (JWK), fingerprint,
privateKey (CryptoKey ECDH, non-extractable),
accountSecret (CryptoKey HKDF, non-extractable),
notificationsAsked (bool), installBannerDismissed (bool)
chats key: id // "dm:<peer>" | "room:<roomId>"
{id, type: "dm"|"room", title, peer?, roomId?, owner?, members?: nick[],
lastId: ULID|null, lastReadId: ULID|null, unread: number, hidden: bool}
messages key: id (ULID)
index "chat": [chatId, id]
{id, chatId, from, text: string|null, ts, status: "pending"|"sent"|"failed",
error?: string, // текст отказа у failed
undecryptable?: "unknown_key"|"bad_aead"|"key_changed", raw?: Envelope}
roomKeys key: [roomId, keyId]
{roomId, keyId, key: CryptoKey AES-GCM non-extractable, from, receivedAt}
peers key: nick
{nick, publicKey: JWK, fingerprint, firstSeen,
pending: {publicKey, fingerprint, seenAt} | null} // новый ключ, ждущий подтверждения
Правила:
- Сообщение пишется в
messagesдо ACK серверу: сначалаput, потомPOST /api/ack. - Входящее сообщение с уже известным
idигнорируется целиком: ни записи, ни счётчика непрочитанных (ADR-034). Повтор доставки не даёт ни дубля в ленте, ни второго непрочитанного;idоткрыт в конверте, и перезапись отдала бы собеседнику чужую строку истории. Перезапись поidостаётся у исходящего: переходpending → sent/failed. - Исходящее пишется со
status: "pending"и локальнымid, затемPOST /api/messages;202→sent, сетевая ошибка и500→ остаётсяpendingи повторяется при следующем подключении; прочие4xx→failedс текстом отказа в полеerror(ADR-033). Повтор отправки идёт с прежним ULID, пока время в нём разошлось с текущим меньше чем на четыре минуты: ответ наPOSTмог потеряться уже после того, как сервер сообщение принял, а повтор с тем жеidполучатель игнорирует (ADR-034). Идентификатор старше запаса заменяется свежим — старая запись удаляется, новая пишется: время вidдолжно совпадать с временем фактической отправки, иначе после долгого офлайна сервер ответитclock_skew.clock_skewна переиспользованномidотменяет переиспользование: попытка идёт второй раз со свежимid, ровно один раз; такой же отказ на свежемid—failedс текстом про часы (ADR-036). unreadиlastReadId— локальные, на сервер не уходят.- Нерасшифрованное сообщение хранит
rawдля повторной попытки после подтверждения нового ключа или получения недостающегоkeyId. - Пагинация — курсор по индексу
chatназад от последнего, по 50. - При старте:
navigator.storage.persist(); в настройках —storage.estimate().
Экспорт .bare
Полезная нагрузка — chats (без unread, lastReadId), messages (без raw и error, только с text), peers (без pending). Формат файла и шифрование — docs/crypto.md. Имя файла — bare-<nick>-<YYYY-MM-DD>.bare.