Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений

Сервер: регистрация устройств и 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
This commit is contained in:
2026-08-22 18:18:04 +03:00
co-authored by Claude Opus 5
parent 597c55301c
commit 63a7a1ef52
39 changed files with 5126 additions and 46 deletions
+120
View File
@@ -0,0 +1,120 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/store"
)
// deviceID — тело и ответ POST /api/devices: идентификатор выдаёт клиент
// (ADR-017), сервер только проверяет форму и принадлежность.
type deviceID struct {
ID string `json:"id"`
}
// POST /api/devices — регистрация устройства. Уже заведённое своё —
// 200 и обновлённый last_seen; занятое чужим — 409, клиент берёт новый id.
func (s *server) createDevice(w http.ResponseWriter, r *http.Request) {
var in deviceID
if !decode(w, r, &in) {
return
}
if !validID(in.ID) {
Invalid(w, "id", "id — не 16 байт base64url")
return
}
sess, _ := auth.From(r)
created, err := s.st.RegisterDevice(r.Context(), in.ID, sess.Nick, sess.TokenHash, time.Now().UnixMilli())
if errors.Is(err, store.ErrDeviceTaken) {
Error(w, http.StatusConflict, "device_conflict", "такое устройство уже есть")
return
}
if err != nil {
s.internal(w, r, err)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, status, deviceID{in.ID})
}
// deviceOut — строка ответа GET /api/devices.
type deviceOut struct {
ID string `json:"id"`
CreatedAt int64 `json:"createdAt"`
LastSeen int64 `json:"lastSeen"`
HasPush bool `json:"hasPush"`
Current bool `json:"current"`
}
// GET /api/devices — устройства аккаунта. Самой push-подписки в ответе
// нет, только факт её наличия.
func (s *server) devices(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
list, err := s.st.Devices(r.Context(), sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
out := make([]deviceOut, 0, len(list))
for _, d := range list {
out = append(out, deviceOut{
ID: d.ID,
CreatedAt: d.CreatedAt,
LastSeen: d.LastSeen,
HasPush: d.HasPush,
Current: d.ID == sess.DeviceID,
})
}
writeJSON(w, http.StatusOK, out)
}
// DELETE /api/devices/{id} — удаление устройства: очередь, подписка
// и сессии уходят каскадом, открытый поток событий закрывается.
//
// Чужое и несуществующее устройство отвечают тем же 204: удалять нечего,
// а отдельного кода на этот случай в протоколе нет (docs/protocol.md).
func (s *server) deleteDevice(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
id := r.PathValue("id")
deleted, err := s.st.DeleteDevice(r.Context(), id, sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
if deleted {
s.hub.Close(id)
}
noContent(w)
}
// device читает X-Device и проверяет, что устройство принадлежит
// пользователю сессии. Заголовка нет, форма кривая, устройство чужое —
// всё это 403 unknown_device (docs/protocol.md, «Общие правила»).
func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) {
sess, _ := auth.From(r)
id := r.Header.Get("X-Device")
if !validID(id) {
unknownDevice(w)
return "", false
}
owned, err := s.st.DeviceOwned(r.Context(), id, sess.Nick)
if err != nil {
s.internal(w, r, err)
return "", false
}
if !owned {
unknownDevice(w)
return "", false
}
return id, true
}
func unknownDevice(w http.ResponseWriter) {
Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше")
}