Files
bare/web/js/api.js
T
mayatnikovandClaude Opus 5 63a7a1ef52 Этап 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
2026-08-22 18:18:04 +03:00

223 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Обёртки над fetch и поток событий. Форма запросов и ответов —
// docs/protocol.md: JSON в обе стороны, cookie сессии, ошибка —
// {error, message}.
// MAX_ACK — сколько идентификаторов принимает один POST /api/ack
// (docs/protocol.md, «Сообщения»).
export const MAX_ACK = 500;
// ApiError — ответ сервера с кодом из перечня docs/protocol.md.
export class ApiError extends Error {
constructor(code, message, status, field) {
super(message || code);
this.name = "ApiError";
this.code = code;
this.status = status;
this.field = field;
}
}
// NetworkError — запрос не дошёл: сети нет, сервер не ответил.
// Это состояние клиента, а не код протокола.
export class NetworkError extends Error {
constructor() {
super("нет соединения");
this.name = "NetworkError";
}
}
// Тексты состояний и ошибок — docs/ui.md и ADR-028.
const TEXT = {
invalid_credentials: "неверный ник или пароль",
nick_taken: "ник занят",
invalid_nick: "ник: 232 символа, az, 09, _",
invite_required: "нужен инвайт-код",
invalid_invite: "инвайт-код не подходит",
rate_limited: "слишком часто, попробуйте позже",
unknown_user: "такого ника нет",
self: "нельзя писать себе",
clock_skew: "проверьте часы на устройстве: расхождение больше 5 минут",
};
export function errorText(err) {
if (err instanceof NetworkError) {
return "нет соединения";
}
if (err instanceof ApiError && TEXT[err.code]) {
return TEXT[err.code];
}
return "сервер не справился, попробуйте позже";
}
// expired вызывается, когда сервер сказал «нужен вход»: сессия истекла
// или её завершили с другого устройства. IndexedDB при этом не трогается
// (docs/ui.md, «Сеть и состояния»).
let expired = () => {};
export function onSessionExpired(handler) {
expired = handler;
}
// quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где
// «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession).
// device: заголовок X-Device — он обязателен там, где важно, с какого
// устройства пришёл запрос (docs/protocol.md, «Общие правила»).
async function request(method, path, body, { quiet = false, device = null } = {}) {
const init = { method, credentials: "same-origin", cache: "no-store" };
const headers = {};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
if (device) {
headers["X-Device"] = device;
}
if (Object.keys(headers).length > 0) {
init.headers = headers;
}
let response;
try {
response = await fetch(path, init);
} catch {
throw new NetworkError();
}
let data = null;
if ((response.headers.get("Content-Type") ?? "").startsWith("application/json")) {
data = await response.json().catch(() => null);
}
if (response.ok) {
return data;
}
const code = typeof data?.error === "string" ? data.error : "internal";
// Отличаем истёкшую сессию от неверного пароля: 401 invalid_credentials —
// обычная ошибка формы входа, 401 unauthenticated — выход на экран входа.
if (code === "unauthenticated" && !quiet) {
expired();
}
throw new ApiError(code, data?.message, response.status, data?.field);
}
export function config() {
return request("GET", "/api/config");
}
export function kdf(nick) {
return request("GET", `/api/kdf?nick=${encodeURIComponent(nick)}`);
}
export function register(body) {
return request("POST", "/api/register", body);
}
export function login(nick, authKey) {
return request("POST", "/api/login", { nick, authKey });
}
export function me() {
return request("GET", "/api/me");
}
// dropSession — служебный выход перед повторным входом (ADR-031). Смена
// пароля и удаление аккаунта входят заново, а вход перезаписывает cookie:
// прежнюю сессию закрываем сами, пока её токен ещё при нас.
//
// 401 unauthenticated здесь означает «сессии и так нет» — это успех, а не
// конец сеанса: следующим шагом идёт login, он заведёт новую. Остальные
// отказы поднимаются наверх: при живой сессии входить заново нельзя,
// её строка осталась бы на сервере без владельца.
export async function dropSession() {
try {
await request("POST", "/api/logout", undefined, { quiet: true });
} catch (err) {
if (!(err instanceof ApiError) || err.code !== "unauthenticated") {
throw err;
}
}
}
export function password(body) {
return request("POST", "/api/password", body);
}
export function deleteMe(authKey) {
return request("DELETE", "/api/me", { authKey });
}
export function user(nick) {
return request("GET", `/api/users/${encodeURIComponent(nick)}`);
}
// --- устройства --------------------------------------------------------
// registerDevice — 201 при создании, 200 если устройство уже наше,
// 409 device_conflict, если идентификатор занят другим (ADR-017).
export function registerDevice(id) {
return request("POST", "/api/devices", { id });
}
export function devices() {
return request("GET", "/api/devices");
}
export function removeDevice(id) {
return request("DELETE", `/api/devices/${encodeURIComponent(id)}`);
}
// --- контакты ----------------------------------------------------------
export function contacts() {
return request("GET", "/api/contacts");
}
// addContact заводит строку списка чатов и отдаёт публичный ключ
// собеседника: 404 unknown_user, 400 self (ADR-019).
export function addContact(nick) {
return request("POST", "/api/contacts", { nick });
}
export function removeContact(nick) {
return request("DELETE", `/api/contacts/${encodeURIComponent(nick)}`);
}
// --- сообщения ---------------------------------------------------------
// sendMessage отдаёт конверт серверу; from и ts он поставит сам (ADR-017).
// Ответ — 202 {id, ts}.
export function sendMessage(device, envelope) {
return request("POST", "/api/messages", envelope, { device });
}
// ack подтверждает запись сообщений в IndexedDB: сервер убирает их
// из очереди устройства (docs/storage.md). Не больше MAX_ACK за раз.
export function ack(device, ids) {
return request("POST", "/api/ack", { ids }, { device });
}
// --- события -----------------------------------------------------------
// stream открывает поток событий устройства (docs/protocol.md, «События»).
// Устройство передаётся в query: EventSource не умеет заголовки.
//
// Переподключение делает браузер сам. Ответ не 200 он считает
// окончательным отказом и больше не подключается — это видно
// по readyState CLOSED и передаётся в handlers.error вторым состоянием.
//
// Отдаёт функцию закрытия потока.
export function stream(device, handlers) {
const source = new EventSource(`/api/events?device=${encodeURIComponent(device)}`);
source.addEventListener("msg", (event) => handlers.msg(parse(event.data)));
source.addEventListener("ready", () => handlers.ready());
source.addEventListener("error", () => handlers.error(source.readyState === EventSource.CLOSED));
return () => source.close();
}
function parse(data) {
try {
return JSON.parse(data);
} catch {
return null;
}
}