Сервер: пакет 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
153 lines
10 KiB
Markdown
153 lines
10 KiB
Markdown
# Хранение
|
||
|
||
## Сервер — SQLite
|
||
|
||
Режим: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`. Версия схемы — `PRAGMA user_version`; миграции — `internal/store/migrations/NNN_*.sql`, встроены через `embed`, применяются по порядку при старте, каждая в транзакции. Время — миллисекунды Unix в `INTEGER`.
|
||
|
||
### Миграция 001
|
||
|
||
```sql
|
||
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
|
||
|
||
```sql
|
||
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), — остальное уносит каскад.
|
||
|
||
### Фоновая чистка, раз в час
|
||
|
||
```sql
|
||
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`; `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`.
|