Перейти к содержимому

00 устройство

Как это устроено

Коротко и без рекламы: что хранится, как считается, где слабые места.

Комната и два ключа

Комната создаётся одним запросом. Сервер выдаёт два случайных числа по 128 бит (16 байт из crypto.getRandomValues, 22 символа base64url):

  • Адрес комнаты в пути /r/<id>. Это пропуск «только просмотр». Угадать его перебором нельзя: 2128 вариантов, а запросы к несуществующим комнатам ещё и дороже считаются в лимите.
  • Ключ редактирования во фрагменте #k=<token>. Фрагмент не отправляется на сервер при открытии страницы и не попадает в журналы. Браузер кладёт ключ в localStorage и отправляет его заголовком Authorization: Bearer только при записи. Сервер хранит SHA-256 ключа, а не сам ключ.

Аккаунтов нет. Потеряли ссылку с ключом, потеряли право редактировать: сбросить или восстановить ключ некому и нечем.

Деньги

Суммы это целое число минорных единиц (копейки, центы, иены). Курс это целое число миллионных долей. Нигде нет float. Перевод валюты и деление считаются через BigInt, потому что произведение суммы на вес может выйти за 253.

  • Округление долей. Метод наибольших остатков: доли округляются вниз, а оставшиеся копейки получают те, у кого остаток больше (при равенстве тот, кто раньше в списке). Сумма долей всегда равна сумме расхода, ни одна копейка не теряется.
  • Способы деления: поровну; по долям (целые веса 1–1000); по точным суммам (должны дать сумму расхода); по процентам (до двух знаков, ровно 100).
  • Мультивалюта. Курс вводится вручную и фиксируется в каждом расходе. Внешних API курсов нет: цены не меняются задним числом. Сумма в валюте комнаты округляется половина вверх, затем делится между участниками теми же наибольшими остатками.

Алгоритм «кто кому»

Для каждого участника считается нетто-баланс: заплатил минус его доли. Сумма балансов равна нулю (проверяется свойство-тестами на тысячах случайных наборов). Дальше жадный алгоритм: самый большой должник платит самому большому кредитору, меньшая из двух сумм обнуляет одного из них.

Оценка: каждый шаг обнуляет минимум одного участника, а последний шаг двух сразу, поэтому переводов не больше k − 1, где k число участников с ненулевым балансом. Применение всех переводов обнуляет все балансы. Это верхняя граница, а не оптимум: поиск настоящего минимума эквивалентен задаче о разбиении множества на подмножества с нулевой суммой и NP-труден. Для компании из десятка человек разница обычно ноль-один платёж.

«Закрыть расчёт» замораживает открытые расходы вместе с вычисленными переводами в историю, и дальше учёт идёт с нуля. Вернуть расход из закрытого периода нельзя.

Одновременные правки

У комнаты есть версия, она растёт на единицу при любом изменении. Клиент присылает версию, которую видел (If-Match).

  • Версия совпала: правка применяется.
  • Версия старая, но никто не менял тот же объект (по журналу изменений): правки сливаются. Два человека добавляют разные расходы одновременно, и оба добавления остаются.
  • Тот же расход или участник уже менялся: ответ 409 conflict с актуальной версией, клиент обновляет данные. Из двух одновременных правок одного расхода побеждает ровно одна.
  • Закрытие расчёта и удаление комнаты требуют точного совпадения версии.

Атомарность обеспечена одним D1-пакетом (это одна транзакция SQLite): первым стоит UPDATE ... WHERE version = N, который записывает одноразовый маркер. Все остальные операции пакета выполняются только если маркер наш. Если в промежутке успел кто-то другой, пакет становится пустым, и сервер заново планирует операцию уже на свежем состоянии.

Архитектура

СлойЧем сделаноПочему так
СерверCloudflare Worker, TypeScript, без фреймворкаОдин файл маршрутов, холодный старт в миллисекундах
ДанныеD1 (SQLite): комнаты, участники, расходы, периоды, журнал, счётчики лимитовТранзакции в пакете нужны для блокировки; Durable Object не требуется
СтатикаWorkers Static Assets, сборка ViteОдна поставка, один домен, строгий CSP без inline
КлиентВанильный TypeScript, без UI-фреймворкаПриложение маленькое; весь пользовательский текст идёт через textContent, что убирает класс XSS по построению
Проверка данныхСвоя небольшая валидация в src/sharedТе же функции работают в браузере и на сервере; нет зависимости на сотни килобайт; свойства проверяются fast-check
ОчисткаCron раз в суткиКомнаты без активности 180 дней удаляются со всеми данными

Модель угроз

УгрозаЧто сделаноОстаётся
Подбор адреса комнаты128 бит; промахи тарифицируются дороже; лимиты по HMAC от IPЗнающий ссылку видит комнату
Кража ключа редактированияХранится SHA-256; ключ только во фрагменте и в заголовке; сравнение за постоянное времяУтечка ссылки из чата или истории браузера даёт полный доступ
Чужой сайт пишет от имени пользователяНет cookie, нет неявных прав; проверка Origin и Sec-Fetch-Site; только application/jsonCORS по умолчанию закрыт
XSS через имена и описанияЧистка управляющих и bidi-символов; вывод только через textContent; CSP script-src 'self', без inlineСкомпрометированный браузер
Формулы в CSVЯчейки, начинающиеся с = + - @, получают апострофПользователь сам выбирает, чем открывать файл
Перебор и перегрузкаЛимиты: участников 30, расходов 5000, тело запроса 32 КБ (импорт 640 КБ), частота запросов по окну в D1Общий NAT делит один лимит
Утечка персональных данныхIP не хранится (только HMAC в счётчике на 20 минут); логов запросов нет; аналитики нетCloudflare видит трафик как провайдер
Потеря данных чужой правкойМягкое удаление, журнал, оптимистичная блокировкаЛюбой с ключом редактирования может удалить комнату целиком (после подтверждения названием)

Чего приложение не делает

  • Нет аккаунтов, паролей и восстановления доступа. Нет шифрования данных «от сервера»: сервер хранит расходы открытым текстом, как любая база. Если нужна тайна от оператора, это не тот инструмент.
  • Подпись в журнале («от кого правка») это пометка в браузере, а не доказательство личности.
  • Один плательщик на расход. Складчина «двое заплатили поровну» записывается двумя расходами.
  • Работа без сети: показывается последняя копия комнаты, а добавление расходов, участников и переводов ставится в очередь. Правки, удаления и закрытие расчёта в очередь не ставятся. Очередь живёт в localStorage и пропадает вместе с ним. Фоновой синхронизации нет.
  • Курсы вводятся вручную; пересчёта старых расходов по новому курсу нет.
  • Расчёт жадный: число переводов не больше n−1, но не всегда минимально возможное.

API

Описание в формате OpenAPI 3.1: /openapi.yaml. Примеры:

curl -s -X POST https://ewsirdev-budget.ewsirdev.workers.dev/api/rooms \
  -H 'content-type: application/json' \
  -d '{"name":"Дача","base":"RUB","members":["Аня","Боря"]}'
# → {"id":"…22 символа…","editToken":"…22 символа…","version":1,…}

curl -s https://ewsirdev-budget.ewsirdev.workers.dev/api/rooms/<id>/settle
# → балансы и переводы; без ключа, только чтение