Files
bare/docs/storage.md
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

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

10 KiB
Raw Blame History

Хранение

Сервер — 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);

Миграция 002

ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0;

Признак «состав уменьшился, нового ключа ещё не было» (ADR-041): ставится при выходе участника и удалении аккаунта, снимается при POST /api/rooms/{id}/members, отдаётся полем needsRekey.

Текущий ключ комнаты для участника — строка room_keys с максимальным created_at; keyId считается ключом комнаты, если есть хоть одна строка с таким key_id для room_id.

Время записи room_keys строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по key_id (ADR-042). Два rekey подряд укладываются в одну миллисекунду, поэтому created_at ключа — не в точности миллисекунды Unix, а миллисекунды, сдвинутые вперёд ровно настолько, чтобы «последний» был однозначен.

Удаление пользователя: перед DELETE FROM users сервер обрабатывает его комнаты — убирает членство и ключи, передаёт владение или удаляет опустевшую комнату, ставит needs_rekey там, где участники остались (ADR-041), — остальное уносит каскад.

Фоновая чистка, раз в час

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), notificationsOff (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}
  // receivedAt строго больше receivedAt всех прежних ключей той же комнаты;
  // текущий ключ — последний по нему, то есть в порядке получения (ADR-042)

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; 202sent, сетевая ошибка и 500 → остаётся pending и повторяется при следующем подключении; прочие 4xxfailed с текстом отказа в поле error (ADR-033). Повтор отправки идёт с прежним ULID, пока время в нём разошлось с текущим меньше чем на четыре минуты: ответ на POST мог потеряться уже после того, как сервер сообщение принял, а повтор с тем же id получатель игнорирует (ADR-034). Идентификатор старше запаса заменяется свежим — старая запись удаляется, новая пишется: время в id должно совпадать с временем фактической отправки, иначе после долгого офлайна сервер ответит clock_skew. clock_skew на переиспользованном id отменяет переиспользование: попытка идёт второй раз со свежим id, ровно один раз; такой же отказ на свежем idfailed с текстом про часы (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.