ADR-015…024 и спецификации v1: криптография, протокол, хранение, UI, деплой, план
Закрыты все открытые вопросы проектирования. Пароль не покидает клиент (два ключа из мастера), TOFU для публичных ключей, устройства и конверт, атомарный rekey комнат, регистрация и контакты, схема SQLite и драйвер без cgo, сессии и CSRF, деплой через nginx+systemd, правила пушей, айдентика «Скобы» на системном mono. Иконки PWA в web/icons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# ADR-002: Сервер на Go, один бинарь
|
||||
|
||||
Уточнён [ADR-020](020-storage-schema-and-driver.md): три прямые зависимости, транзитивные допускаются; драйвер — `modernc.org/sqlite`. Развёртывание — [ADR-022](022-deploy-nginx-systemd.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
Серверу Bare нужно немного: HTTP, SSE, SQLite, хеширование паролей, Web Push. Простота развёртывания и аудита важнее богатства экосистемы.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ADR-005: Аккаунт — ник и пароль
|
||||
|
||||
Уточнён [ADR-015](015-password-never-leaves-client.md): на сервер уходит не пароль, а выведенный из него `authKey`. Открытость регистрации закрыта [ADR-019](019-registration-and-contacts.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
Email, телефон и OAuth тянут за собой внешние сервисы, интеграции и утечку идентичности. Bare — независимый инструмент без внешних завязок.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ADR-006: E2EE на WebCrypto, ключ за паролем
|
||||
|
||||
Уточнён [ADR-015](015-password-never-leaves-client.md) (два ключа из мастера) и [ADR-016](016-key-trust-tofu.md) (доверие к публичным ключам).
|
||||
|
||||
## Контекст
|
||||
|
||||
Оператор не должен уметь читать сообщения. Крипто-библиотеки на клиенте противоречат нулю зависимостей и аудируемости — вся криптография должна быть нативной.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ADR-007: Симметричный ключ комнаты и rekey
|
||||
|
||||
Конкретизирован [ADR-018](018-rooms-membership-rekey.md): владелец, случайный `keyId`, атомарный rekey, постоянное хранение завёрнутых ключей.
|
||||
|
||||
## Контекст
|
||||
|
||||
Сообщение в комнате должны читать все участники, но не сервер. Шифровать каждое сообщение отдельно каждому участнику — квадратичный объём работы и трафика.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ADR-008: Сервер — реле с per-device очередью
|
||||
|
||||
Уточнён [ADR-017](017-devices-and-envelope.md) (идентификация устройства, конверт, ACK) и [ADR-018](018-rooms-membership-rekey.md): к метаданным комнат относятся завёрнутые ключи.
|
||||
|
||||
## Контекст
|
||||
|
||||
Сервер никогда не является местом, где живёт история (философия, п. 2). Но получатель бывает офлайн — сообщение надо где-то подержать до доставки.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ADR-011: Web Push + VAPID, пуш — сигнал
|
||||
|
||||
Правила отправки и service worker — [ADR-023](023-push-and-service-worker.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
Без уведомлений чат бесполезен. Firebase SDK и вендорские кабинеты — зависимость и завязка, несовместимые с философией.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# ADR-015: Пароль не покидает клиент — два ключа из одного мастера
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-005 и ADR-006 используют один пароль и для серверной аутентификации (Argon2id), и как материал ключа шифрования блоба. Если клиент отправляет пароль на сервер в открытом виде, оператор, логирующий тела запросов, получает материал ключа — и обещание «оператор не читает сообщения» рушится на первом же входе. Кроме того, ADR-013 требует повышать число итераций KDF без миграции всех аккаунтов разом, а смена пароля числится открытым вопросом.
|
||||
|
||||
## Решение
|
||||
|
||||
- Пароль никогда не отправляется на сервер. Клиент выводит мастер-ключ: `master = PBKDF2-HMAC-SHA256(NFC(пароль), salt = SHA-256("bare-v1:" + nick), iterations, 256 бит)`. Соль детерминированная — известна до входа без запроса к серверу.
|
||||
- Из мастера через HKDF-SHA256 выводятся два независимых ключа: `authKey = HKDF(master, info="bare-auth-v1")` — 32 байта, уходит на сервер как «пароль»; `kek = HKDF(master, info="bare-kek-v1")` — AES-GCM-256, шифрует ключевой блоб и сервер его не видит.
|
||||
- Сервер хранит `argon2id(authKey)`. Argon2id остаётся (ADR-002, ADR-005): он защищает дамп базы от использования `authKey` как готового пароля для входа.
|
||||
- Перед входом клиент спрашивает `GET /api/kdf?nick=` и получает число итераций. Для несуществующего ника сервер отвечает текущим целевым значением — ответ не раскрывает существование ника.
|
||||
- Повышение итераций и смена пароля — одна и та же операция `POST /api/password`: клиент, имея пароль в памяти, выводит новый `authKey`, перешифровывает блоб новым `kek` и отправляет оба вместе со старым `authKey` для подтверждения. Сервер заменяет хеш и блоб атомарно. Смена пароля входит в v1.
|
||||
- Смена пароля по желанию пользователя завершает остальные сессии (`logoutOthers: true`); автоматическое повышение итераций — нет.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Пассивный оператор не получает материал ключа ни при регистрации, ни при входе. Модель угроз становится честной.
|
||||
- Соль из ника означает, что перерегистрация под тем же ником с тем же паролем даёт тот же мастер. Ключевая пара при этом новая — старые архивы нечитаемы (ADR-014), мастер это не спасает.
|
||||
- PBKDF2 выполняется один раз на вход; на слабом телефоне 1 000 000 итераций — до нескольких секунд. UI показывает «вычисляем ключ».
|
||||
- Вопрос «смена пароля в v1 или позже» закрыт: в v1.
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-016: Доверие к ключам — TOFU и отпечаток
|
||||
|
||||
## Контекст
|
||||
|
||||
Публичные ключи собеседников клиент получает от сервера. Сервер, подменивший ключ, становится посредником в чате 1:1 и получает ключ комнаты при rekey. Модель угроз описывает подмену клиентского кода, но не подмену ключа — это отдельный, более дешёвый для оператора вектор. Подписи сообщений потребовали бы вторую ключевую пару (ECDH-ключ P-256 в WebCrypto не подписывает) и усложнили бы протокол.
|
||||
|
||||
## Решение
|
||||
|
||||
- Trust On First Use. Клиент запоминает публичный ключ ника при первом получении (хранилище `peers` в IndexedDB). При каждом последующем получении ключа сверяет с запомненным.
|
||||
- Отпечаток ключа — `SHA-256(raw-точка публичного ключа P-256, 65 байт)`, показывается как 64 hex-символа группами по 4. Свой отпечаток виден в настройках; чужой — в карточке контакта. Сверка — вне канала, голосом или лично.
|
||||
- Изменение ключа — не ошибка, а состояние: в чате появляется предупреждение «ключ @nick изменился, сверьте отпечаток». Отправка этому нику блокируется до явного «доверять новому ключу». Входящие, зашифрованные новым ключом, показываются как нерасшифрованные с той же подсказкой.
|
||||
- Rekey комнаты участнику с изменившимся и не подтверждённым ключом не выполняется: владелец видит, чей ключ надо подтвердить, и повторяет операцию после подтверждения.
|
||||
- Подписей сообщений в v1 нет. Отправитель в конверте проставляется сервером из сессии. В 1:1 подлинность следует из самого ключа: валидный шифротекст может создать только владелец общего секрета. В комнате любой участник может создать валидный шифротекст от чужого имени только в сговоре с сервером, который проставляет `from`.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Сервер получает возможность подмены ключа только при первом контакте; после этого подмена видна.
|
||||
- Защита стоит ровно столько, сколько люди готовы сверять отпечатки. Это честно записано в модели угроз.
|
||||
- Новое устройство начинает с пустым хранилищем TOFU; импорт архива `.bare` переносит и его.
|
||||
- Подписи и второй ключ — возможное расширение отдельным ADR, если появится требование защиты от сговора участника с сервером.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR-017: Устройства, конверт сообщения и доставка
|
||||
|
||||
## Контекст
|
||||
|
||||
Очередь per-device (ADR-008) требует идентификации устройства. Формат конверта определяет, какие метаданные видит сервер, — это часть модели угроз, а не деталь реализации. Время сообщения: клиентский ULID (ADR-009) несёт часы клиента, которые врут.
|
||||
|
||||
## Решение
|
||||
|
||||
**Устройство.** Клиент при первом входе на устройстве генерирует `deviceId` — 16 случайных байт, base64url — и хранит его в IndexedDB. Регистрирует через `POST /api/devices`; идентификатор принадлежит аккаунту. Заголовок `X-Device` обязателен на запросах, где важно устройство: ACK, отправка (чтобы не возвращать эхо отправившему устройству), push-подписка; поток событий получает устройство в query — `EventSource` не умеет заголовки. Устройство, не появлявшееся 90 дней, удаляется вместе с очередью и подпиской.
|
||||
|
||||
**Конверт.** JSON, открытые поля: `id` (ULID, генерирует клиент), `to` (`{dm: nick}` или `{room: id}`), `from` (ставит сервер из сессии, клиентское значение игнорируется), `keyId` (`"dm"` для 1:1, идентификатор ключа для комнаты), `iv`, `ct`, `ts` (миллисекунды сервера). Внутри шифротекста — JSON `{t: текст}`. Открытые поля привязаны к шифротексту через AAD: `bare-msg-v1|id|chat|from|keyId`, где `chat` — `dm:a:b` (ники по возрастанию) или `room:id`.
|
||||
|
||||
**Часы.** Сервер принимает сообщение, только если метка времени в ULID отличается от серверных часов не больше чем на 5 минут; иначе `400 clock_skew` и клиент просит проверить часы. ULID присваивается в момент попытки отправки, не в момент набора: отложенное офлайном сообщение получает свежий идентификатор при повторе. Сортировка — по `id`, отображение времени — по `ts`.
|
||||
|
||||
**Доставка.** `POST /api/messages` в одной транзакции кладёт конверт в очередь каждого устройства каждого получателя (в 1:1 получатели — оба ника, в комнате — все участники), кроме устройства-отправителя. Подключённым по SSE устройствам конверт отправляется сразу. `POST /api/ack {ids}` удаляет конверты из очереди устройства. При подключении SSE сервер сначала отдаёт всю очередь устройства, затем `event: ready`, затем живые события. Каждые 20 секунд — комментарий-пинг.
|
||||
|
||||
**Идемпотентность.** Повторный `POST` с тем же `id` после ACK получателей породит повторную доставку; клиент сливает по `id` и дублей не показывает. Сервер не хранит историю идентификаторов — это противоречило бы ADR-008.
|
||||
|
||||
**Лимиты.** Текст — до 4000 символов, тело запроса — до 32 КиБ. Rate limiting — ADR-021.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Серверу видны: кто, кому или в какую комнату, когда и какого размера. Ровно то, что модель угроз и так относит к метаданным.
|
||||
- Отправитель получает своё сообщение на другие устройства тем же путём, что и получатели: мультидевайс без отдельной логики.
|
||||
- Устройство определяется браузерным профилем: два браузера на одном телефоне — два устройства.
|
||||
- Чистка IndexedDB браузером стирает `deviceId`; следующий вход создаёт новое устройство, старое отомрёт по сроку.
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-018: Комнаты — владелец, состав и атомарный rekey
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-007 задаёт принцип: симметричный ключ комнаты, раздача на публичные ключи, rekey при смене состава. Не определено: кто меняет состав, как ключ попадает на новое устройство участника, что происходит при гонке двух rekey и при выходе участника.
|
||||
|
||||
## Решение
|
||||
|
||||
- Комнату создаёт любой пользователь и становится её владельцем. Владелец добавляет и удаляет участников по нику, может удалить комнату. Любой участник может выйти. Приглашений по ссылке нет. Имя комнаты — до 64 символов, открытый текст на сервере: это метаданные.
|
||||
- Ключ комнаты — 32 случайных байта с идентификатором `keyId` (16 случайных байт, base64url). Идентификатор случайный, а не порядковый: гонка двух одновременных rekey даёт два разных ключа, оба доходят до всех, конфликта номеров нет. Текущий ключ — последний полученный в порядке сервера; сообщения несут `keyId`, клиент держит все ключи комнаты и расшифровывает любым известным.
|
||||
- Ключ участнику заворачивается на ECDH между распространителем и участником: `HKDF(ECDH(priv_D, pub_M), info="bare-roomkey-v1|roomId|keyId") → AES-GCM`. Распространитель заворачивает ключ и себе — для собственных других устройств.
|
||||
- Завёрнутые ключи сервер хранит постоянно, не в транзитной очереди: таблица `room_keys`, по одной записи на участника и ключ, последние два ключа комнаты. Новое устройство участника получает текущий ключ вместе со списком комнат. Это уточняет ADR-008: к «метаданным комнат» относятся и завёрнутые ключи — шифротекст, серверу бесполезный.
|
||||
- Смена состава и rekey — один запрос `POST /api/rooms/{id}/members {add, remove, keyId, keys}`. Клиент-владелец сначала получает публичные ключи итогового состава (с проверкой TOFU, ADR-016), генерирует ключ, заворачивает каждому, затем отправляет. Сервер проверяет, что множество `keys[].to` равно итоговому составу, и применяет всё в одной транзакции. Состав без ключа или ключ без состава невозможны.
|
||||
- Выход участника: сервер удаляет его из состава и его ключи, шлёт остальным событие `room` с `needsRekey: true`. Клиент владельца, получив его, выполняет rekey тем же запросом с пустыми `add`/`remove`. Пока владелец офлайн, комната живёт на старом ключе — вышедший его и так знает; новых сообщений сервер ему не доставляет.
|
||||
- Выход владельца передаёт владение участнику с самым ранним `joined_at`. Выход последнего участника удаляет комнату.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Серверу ключи недоступны по-прежнему: он хранит и раздаёт только шифротекст.
|
||||
- Владелец — единственная роль. Администраторов, модераторов и прав на уровне сообщений нет.
|
||||
- Новый участник не читает прошлое: его не существует на сервере. Сообщение, отправленное на старом ключе одновременно с rekey, новое устройство прочитать не сможет — показывается как нерасшифрованное. Редкий и честный случай.
|
||||
- Если член комнаты сговорился с сервером, он может остаться читателем после выхода до rekey. В модели угроз сервер и участник по отдельности не защищаемые стороны; их сговор — тем более.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-019: Регистрация, ники и контакты
|
||||
|
||||
## Контекст
|
||||
|
||||
Открытые вопросы: открытая регистрация или инвайты; как добавляется контакт и начинается чат 1:1. Bare — маленький инструмент для небольших групп, а не публичная сеть.
|
||||
|
||||
## Решение
|
||||
|
||||
- Ник: `^[a-z0-9_]{2,32}$`. Только строчные — уникальность без регистровых коллизий и омоглифов. Ник постоянен, смены нет.
|
||||
- Регистрация открыта. Оператор может задать один общий инвайт-код (`BARE_INVITE_CODE` в окружении); если задан, регистрация требует его. Персональных инвайтов, ссылок и списков нет.
|
||||
- Чат 1:1 начинается с ввода ника: клиент получает публичный ключ (`GET /api/users/{nick}`) и пишет. Согласия получателя не требуется — как в e-mail. Контакт — строка в списке чатов, которую сервер заводит обеим сторонам при первом сообщении в любую сторону, чтобы новое устройство видело список чатов без истории. `DELETE /api/contacts/{nick}` убирает чат из списка, не блокируя собеседника.
|
||||
- Блокировки в v1 нет. Защита от спама — инвайт-код и rate limiting (ADR-021).
|
||||
- Удаление аккаунта: `DELETE /api/me` с подтверждением `authKey` удаляет аккаунт, устройства, контакты, членство и очереди. Комнаты, где пользователь владелец, передаются по правилу ADR-018.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Ник — публичный идентификатор; что он существует, узнать можно. Это не секрет и не считается утечкой.
|
||||
- Общий инвайт-код — барьер от ботов, не от людей, которым его передали. Большего v1 не обещает.
|
||||
- Блокировка и персональные инвайты — кандидаты на следующие ADR, если понадобятся.
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-020: Схема SQLite, миграции и драйвер
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-003 выбирает SQLite, но не схему и не драйвер. Выбор драйвера решает, нужен ли cgo: от этого зависит, можно ли собрать бинарь для Linux на Mac одной командой. Формулировка «ровно три внешних пакета» требует уточнения: прямые зависимости или всё содержимое `go.sum`.
|
||||
|
||||
## Решение
|
||||
|
||||
- Драйвер — `modernc.org/sqlite`, чистый Go. Сборка с `CGO_ENABLED=0`, кросс-компиляция тривиальна.
|
||||
- «Три внешних пакета» — три прямые зависимости в `go.mod`: `modernc.org/sqlite`, `github.com/SherClockHolmes/webpush-go`, `golang.org/x/crypto` (ради `argon2`). Их транзитивные зависимости допускаются: они не выбираются нами и не импортируются напрямую.
|
||||
- Режим базы: `journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON`, `synchronous=NORMAL`. Один файл, путь из конфигурации.
|
||||
- Миграции — нумерованные SQL-файлы, встроенные в бинарь через `embed`. Версия схемы — `PRAGMA user_version`. При старте сервер применяет недостающие миграции по порядку, каждую в своей транзакции. Откатов нет: новая миграция исправляет предыдущую.
|
||||
- Таблицы: `users`, `sessions`, `devices`, `contacts`, `rooms`, `room_members`, `room_keys`, `queue`. Полная схема — `docs/storage.md`. Никаких таблиц с историей сообщений.
|
||||
- Фоновые задачи раз в час: удаление из `queue` записей старше 30 дней, устройств с `last_seen` старше 90 дней, истёкших сессий, лишних ключей комнат сверх двух последних.
|
||||
|
||||
## Следствия
|
||||
|
||||
- `go build` без тулчейна C. Деплой — `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`.
|
||||
- `modernc.org/sqlite` медленнее cgo-варианта в разы на тяжёлых запросах. Для очереди и метаданных маленького чата это незаметно.
|
||||
- Бэкап — копия файла базы при остановленном сервере или `VACUUM INTO`. WAL-файл без основного файла бесполезен.
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR-021: Сессии, CSRF, Argon2id и лимиты
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-005 задаёт «Argon2id, сессия в httpOnly cookie» без параметров. Cookie плюс `fetch POST` — классическая поверхность для CSRF. Лимиты и защита от перебора — открытый вопрос.
|
||||
|
||||
## Решение
|
||||
|
||||
**Argon2id.** Вход — `authKey` (32 случайных байта с точки зрения сервера, ADR-015), поэтому параметры умеренные: memory 19 MiB, time 2, parallelism 1, соль 16 байт, выход 32 байта. Параметры записываются рядом с хешем; повышение — перехеш при очередном входе.
|
||||
|
||||
**Сессия.** Токен — 32 случайных байта; в базе хранится `SHA-256(токен)`. Cookie `bare_session`: `HttpOnly; Secure; SameSite=Strict; Path=/`; срок 90 дней без продления. После регистрации устройства сессия привязывается к нему. `POST /api/logout` удаляет сессию; смена пароля по желанию завершает остальные; удаление устройства завершает его сессии.
|
||||
|
||||
**CSRF.** Два независимых барьера: `SameSite=Strict` и проверка заголовка `Origin` на всех запросах кроме `GET`/`HEAD` — он обязан равняться `BARE_ORIGIN`. Приложение живёт на одном origin, сторонних встраиваний нет.
|
||||
|
||||
**Заголовки.** `Content-Security-Policy: default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`. Никаких inline-скриптов и inline-стилей — CSP их запрещает, и это правило для клиентского кода. `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, HSTS на nginx.
|
||||
|
||||
**Лимиты.** Token bucket в памяти сервера:
|
||||
- регистрация — 5 в час на IP;
|
||||
- вход — 10 за 10 минут на пару IP+ник;
|
||||
- сообщения — 30 в минуту на пользователя, пакет 10;
|
||||
- остальные изменяющие запросы — 60 в минуту на пользователя.
|
||||
Превышение — `429` с `Retry-After`. IP берётся из `X-Real-IP`, только если соединение с `127.0.0.1` (nginx, ADR-022).
|
||||
|
||||
**Размеры.** Тело запроса — до 32 КиБ, текст сообщения — до 4000 символов, имя комнаты — до 64, ник — до 32.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Сторонний сайт не может ни отправить сообщение, ни выйти из аккаунта от имени пользователя.
|
||||
- Лимиты живут в памяти: рестарт их обнуляет. Для маленького сервера это приемлемо.
|
||||
- 90-дневный вход без продления — раз в квартал пароль вводится заново на каждом устройстве.
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-022: Деплой — nginx, systemd, кросс-сборка
|
||||
|
||||
## Контекст
|
||||
|
||||
Целевой сервер (`ssh xmatic`, Ubuntu 22.04) уже держит nginx на 80/443 с десятком сайтов и certbot. Go на сервере нет. HTTPS обязателен (ADR-002), но TLS в самом бинаре означал бы либо `autocert` — четвёртую зависимость, — либо конфликт за 443 с nginx.
|
||||
|
||||
## Решение
|
||||
|
||||
- TLS терминирует nginx. Bare слушает `127.0.0.1:8411` (порт свободен; 8090 занят PocketBase). Сертификат — certbot для `bare.xmatic.team`, как у остальных сайтов на машине.
|
||||
- nginx проксирует всё на бинарь; для `/api/events` — `proxy_buffering off`, `proxy_read_timeout 1h`, HTTP/1.1 к апстриму. Сервер дополнительно шлёт `X-Accel-Buffering: no`. Конфиг — `docs/deploy.md`.
|
||||
- Бинарь под systemd: пользователь `bare`, `/opt/bare/bare`, база в `/var/lib/bare/bare.db`, секреты в `/etc/bare/env` (режим 0600). Юнит с `ProtectSystem=strict`, `ProtectHome=yes`, `NoNewPrivileges=yes`.
|
||||
- Конфигурация — переменные окружения с префиксом `BARE_`: `ADDR`, `DB`, `ORIGIN`, `VAPID_PUBLIC`, `VAPID_PRIVATE`, `VAPID_SUBJECT`, `INVITE_CODE`. Подкоманда `bare vapid` генерирует пару ключей. Подкоманда `bare serve` запускает сервер.
|
||||
- Клиентская статика встроена в бинарь через `embed`: артефакт деплоя — ровно один файл, и обещание ADR-001 «код в продакшене байт в байт совпадает с репозиторием» проверяется сравнением с тегом.
|
||||
- Сборка локально: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`. Деплой — `scripts/deploy.sh`: сборка, `scp`, `install`, `systemctl restart`. Без контейнеров.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Проверка подлинности клиента сводится к проверке бинаря: хеш файла на сервере против сборки из тега.
|
||||
- Зависимость от чужого nginx на той же машине — осознанная: он уже там и уже умеет сертификаты.
|
||||
- Статику отдаёт Go, не nginx: заголовки безопасности и ETag в одном месте.
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-023: Правила пушей и service worker
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-011 задаёт принцип «пуш — сигнал». Не определено, когда именно слать пуш, как он привязан к устройству и что кэширует service worker.
|
||||
|
||||
## Решение
|
||||
|
||||
- Push-подписка принадлежит устройству (`devices.push_subscription`). Ставится `PUT /api/devices/{id}/push`, снимается `DELETE`.
|
||||
- Пуш отправляется при постановке сообщения в очередь устройства, если выполняются оба условия: устройство не подключено по SSE и у устройства не висит неотработанный пуш (`push_pending = 0`). После отправки `push_pending = 1`; сбрасывается при подключении SSE. Одно молчащее устройство получает один пуш, не ленту.
|
||||
- Полезная нагрузка: `{title, body: "новое сообщение", chat}` — `title` это `@nick` или `#имя комнаты`, `chat` — идентификатор для перехода. `TTL` 24 часа, urgency `normal`. Ответы 404/410 от push-сервиса удаляют подписку.
|
||||
- Service worker: `push` → `showNotification` с `tag = chat` (новое уведомление заменяет старое в том же чате); `notificationclick` → фокус открытого окна или открытие `/#/<chat>`.
|
||||
- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`.
|
||||
- Разрешение на уведомления запрашивается после первого отправленного сообщения (ADR-011). На iOS вне установленного PWA вместо запроса показывается баннер установки.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Сервер знает только, что у устройства есть что забрать; содержимое в пуше не появляется.
|
||||
- Пользователь с пятью непрочитанными чатами получает один пуш про первый. Остальное — при открытии. Осознанно.
|
||||
- Релиз без смены версии в `sw.js` обновит статику только по ETag при следующем revalidate, не мгновенно.
|
||||
@@ -0,0 +1,21 @@
|
||||
# ADR-024: Айдентика «Скобы», интерфейс и язык
|
||||
|
||||
## Контекст
|
||||
|
||||
Исследование айдентики (Claude Design, «Исследование айдентики Bare») дало шесть направлений и две мини-айдентики; мок чата построен на варианте 1h «Скобы» и использует только моноширинный шрифт, без «пузырей». Открытые вопросы: язык интерфейса и i18n, визуальная айдентика. Мок содержит элементы, которых в scope v1 нет.
|
||||
|
||||
## Решение
|
||||
|
||||
- Айдентика — 1h «Скобы»: знак из четырёх углов, палитра bone/ink/mark/stone, разметочная эстетика — тонкие линии, много воздуха, прямые углы. Бриф — `docs/identity/brief.md`, знак — `docs/identity/mark.svg`, эталонные экраны — `docs/identity/screens.html`.
|
||||
- Шрифт интерфейса — системный моноширинный стек: `ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace`. Fragment Mono из исследования не содержит базовой кириллицы (U+0400–045F) — в моке русский текст и так рендерился фолбэком. Ноль загрузок шрифтов: продукт не тянет ничего извне.
|
||||
- Одна тема — светлая. Тёмной темы и переключателя в v1 нет.
|
||||
- Язык — русский, строки в коде. i18n не закладывается; появление второго языка — отдельный ADR.
|
||||
- Из мока исключены как не входящие в v1: тема канала в шапке, счётчик «N онлайн» и присутствие вообще. Остаются: список каналов и личных, счётчик непрочитанных, разделители дат и «новые», строка ввода с `>` и подсказкой `enter — отправить`, подпись «ты: @nick».
|
||||
- Экраны, которых в исследовании нет (вход, контакт, участники, настройки, предупреждение о ключе, баннер установки), описаны словами в `docs/ui.md` в той же системе.
|
||||
- Non-goals интерфейса v1: присутствие, «печатает», статусы прочтения, аватары, темы, анимации.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Клиент без единого внешнего ресурса: CSP `default-src 'self'` без исключений.
|
||||
- Вид зависит от системного шрифта платформы; это принято — разметка, а не брендбук.
|
||||
- Иконки PWA — растеризованный знак, лежат в репозитории как бинарные файлы; это ассеты, не сборка.
|
||||
Reference in New Issue
Block a user