Комната и два ключа
Комната создаётся одним запросом. Сервер выдаёт два случайных числа по 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/json | CORS по умолчанию закрыт |
| 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
# → балансы и переводы; без ключа, только чтение