Implement ecosystem contract v2: capabilities, shared ledgers, attachments
- GET /v1/capabilities and the extended sync entity allowlist (§0, §1). - Shared ledgers (§7): ledgers, members and hashed one-time invite codes; X-Balance-Ledger selects the space on /v1/sync and /v1/attachments with its own records, change stream and cursor. Personal space keeps the v1 tables and behaviour; the migration only adds tables. - Attachments (§8): SHA-256, size and media type checks, blob files in BALANCE_ATTACHMENTS_DIR (default: attachments next to the database), garbage collection of unreferenced blobs with a grace period for fresh uploads, and ledger deletion removes its blobs. - Tests for every new endpoint and error code; API, sync, data model, security, deployment and README docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10272f3851
commit
dfb4fe4e1a
17 files changed
+2496
-56
No files matched your search
+73
-4
@@ -82,14 +82,21 @@ POST /v1/sync
|
||||
}
|
||||
```
|
||||
|
||||
Поддерживаемые entity:
|
||||
Поддерживаемые entity (контракт v2, `docs/CONTRACT_V2.md` §1):
|
||||
|
||||
```text
|
||||
transaction
|
||||
category
|
||||
budget
|
||||
transaction category budget account transfer recurring
|
||||
goal debt rate setting attachment
|
||||
```
|
||||
|
||||
Незнакомая entity отклоняется `400 invalid_change`; текст сообщения
|
||||
(`entity must be transaction, category, or budget`) сохранён из v1, потому что
|
||||
он записан в эталоне `tools/compatibility_expected.txt`. Payload сервер хранит
|
||||
как есть и не валидирует по сущности.
|
||||
|
||||
Заголовок `X-Balance-Ledger: <id>` выбирает общее пространство (см. ниже). Без
|
||||
заголовка (или с пустым значением) — личное пространство, как в v1.
|
||||
|
||||
## Push
|
||||
|
||||
Client отправляет локальные изменения.
|
||||
@@ -112,6 +119,68 @@ Server возвращает изменения после этого cursor, в
|
||||
|
||||
Если существует pagination, ответ должен содержать понятный признак продолжения и новый cursor/sequence.
|
||||
|
||||
## Контракт v2
|
||||
|
||||
### Capabilities
|
||||
|
||||
`GET /v1/capabilities` — без авторизации:
|
||||
|
||||
```json
|
||||
{"contract":2,
|
||||
"entities":["transaction","category","budget","account","transfer","recurring","goal","debt","rate","setting","attachment"],
|
||||
"attachments":{"maxBytes":10485760,"types":["image/jpeg","image/png","image/heic","application/pdf"]},
|
||||
"ledgers":true}
|
||||
```
|
||||
|
||||
### Общие пространства (ledgers)
|
||||
|
||||
Все запросы — с `Authorization`. Объект ledger:
|
||||
`{"id","name","role","members":[{"userId","email","role"}]}`, `role` —
|
||||
роль текущего пользователя (`owner` | `member`), владелец идёт первым в `members`.
|
||||
|
||||
| Метод | Путь | Успех | Кто |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/v1/ledgers` | `200 {"ledgers":[…]}` (только общие) | любой |
|
||||
| POST | `/v1/ledgers` `{"name"}` | `201` ledger, `role:"owner"` | любой |
|
||||
| PATCH | `/v1/ledgers/{id}` `{"name"}` | `200` ledger | владелец |
|
||||
| DELETE | `/v1/ledgers/{id}` | `204` | владелец; удаляет записи, курсор, приглашения, участников и файлы вложений |
|
||||
| POST | `/v1/ledgers/{id}/invites` | `201 {"code","expiresAt"}` | владелец |
|
||||
| POST | `/v1/ledgers/join` `{"code"}` | `200` ledger, `role:"member"` | любой |
|
||||
| DELETE | `/v1/ledgers/{id}/members/{userId}` | `204` | владелец — любого участника; участник — себя |
|
||||
|
||||
- Имя: после `trim` 1–100 символов без управляющих → иначе `400 invalid_name`.
|
||||
- Код приглашения: 10 символов из `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, живёт 7 дней,
|
||||
одноразовый. При вводе регистр, пробелы и `-` игнорируются. Повторный join
|
||||
участника возвращает ledger и код не расходует. `/v1/ledgers/join` входит в
|
||||
общий лимит 30 запросов/мин на IP вместе с auth endpoints.
|
||||
- Ошибки: `400 invalid_code` (неверный, использованный или просроченный код),
|
||||
`403 forbidden` (не участник или не владелец), `404 not_found` (нет такого
|
||||
пространства или участника), `409 owner_cannot_leave` (владелец пытается
|
||||
выйти — он может только удалить пространство), `400 invalid_json`.
|
||||
- `X-Balance-Ledger` на `/v1/sync` и `/v1/attachments/*`: не участник → `403
|
||||
forbidden`, несуществующее → `404 not_found`.
|
||||
|
||||
### Вложения
|
||||
|
||||
| Метод | Путь | Ответ |
|
||||
| --- | --- | --- |
|
||||
| PUT | `/v1/attachments/{sha256}` | тело — байты; `201` первый раз, `200` повтор; `{"sha256","size","contentType"}` |
|
||||
| GET/HEAD | `/v1/attachments/{sha256}` | `200`, байты, сохранённый `Content-Type`, `Content-Disposition: attachment`; `404 not_found`, если в пространстве нет файла |
|
||||
|
||||
- `{sha256}` — 64 hex-символа в нижнем регистре; SHA-256 тела должен совпасть,
|
||||
иначе `400 hash_mismatch` (так же для неверного формата хеша в PUT).
|
||||
- `Content-Type` (параметры игнорируются) — `image/jpeg`, `image/png`,
|
||||
`image/heic`, `application/pdf`, иначе `415 unsupported_media_type`.
|
||||
- Больше 10 MiB → `413 payload_too_large`. Лимит тела 10 MiB действует только для
|
||||
авторизованного PUT; остальные запросы по-прежнему ограничены 2 MiB.
|
||||
|
||||
### CORS
|
||||
|
||||
Ответы v1 сохраняют заголовки эталона Go. Для маршрутов v2 и для настоящих
|
||||
preflight-запросов (есть `Access-Control-Request-Method`) разрешённые origin
|
||||
получают `Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS`
|
||||
и `Access-Control-Allow-Headers: Authorization, Content-Type, X-Balance-Ledger`.
|
||||
|
||||
## Ошибки
|
||||
|
||||
Рекомендуемый контракт:
|
||||
|
||||
@@ -23,7 +23,7 @@ Android ───────────┘
|
||||
|
||||
## Исходный код
|
||||
|
||||
Production runtime — один Rust-процесс: `src/main.rs`, `runtime.rs`, `config.rs`, `database.rs`, `auth.rs`, `sync.rs`, `server.rs`, `go_json.rs`. SQLite bundled через rusqlite; HTTP обслуживается axum/hyper. `runtime.rs` сохраняет socket deadlines Go (header 5s, read 15s, write 30s, idle 60s) и ограничивает graceful shutdown десятью секундами. `go_json.rs` сохраняет особенности encoding/json и исходные payload bytes. Статические файлы из `web/` встроены при компиляции.
|
||||
Production runtime — один Rust-процесс: `src/main.rs`, `runtime.rs`, `config.rs`, `database.rs`, `auth.rs`, `sync.rs`, `server.rs`, `go_json.rs`, `ledgers.rs` (общие пространства контракта v2), `attachments.rs` (файлы вложений на диске и их сборка мусора). SQLite bundled через rusqlite; HTTP обслуживается axum/hyper. `runtime.rs` сохраняет socket deadlines Go (header 5s, read 15s, write 30s, idle 60s) и ограничивает graceful shutdown десятью секундами. `go_json.rs` сохраняет особенности encoding/json и исходные payload bytes. Статические файлы из `web/` встроены при компиляции. Маршруты контракта v2 (`/v1/capabilities`, `/v1/ledgers*`, `/v1/attachments/*`) разбираются в `server.rs` до маршрутов v1, которые сохраняют поведение эталона Go.
|
||||
|
||||
Исходная Go-реализация удалена после v1.0.0 (исходники остались в теге `v1.0.0`). Её наблюдаемое поведение зафиксировано в `tools/compatibility_expected.txt`, а SQLite-схема — в `tests/fixtures/go_schema_v1.sql`; Rust сверяется с обоими в CI.
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Контракт экосистемы v2
|
||||
|
||||
> Общий документ BalanceServer, BalanceFlutter и BalanceWatch. Одинаковая
|
||||
> копия лежит в каждом репозитории: `docs/ecosystem/CONTRACT_V2.md`
|
||||
> (Flutter), `docs/CONTRACT_V2.md` (сервер, часы). Меняется только во всех
|
||||
> трёх сразу.
|
||||
|
||||
v2 добавляет счета и переводы, регулярные операции, мультивалютность, цели,
|
||||
теги, вложения (фото чеков), общие бюджеты и долги. Всё сделано
|
||||
**добавлением**: формат `/v1/sync`, курсор, `sequence`, `updatedAt`,
|
||||
tombstone и last-write-wins не меняются. Клиент v1 продолжает работать с
|
||||
сервером v2, но не видит новых данных (ограничения — в конце).
|
||||
|
||||
## 0. Общие правила
|
||||
|
||||
1. **Незнакомая сущность** при pull пропускается молча (курсор двигается).
|
||||
2. **Незнакомые поля** payload знакомой сущности клиент v2 **сохраняет** и
|
||||
отправляет обратно без изменений при следующем сохранении записи
|
||||
(forward compatibility). Клиент v1 их теряет — поэтому обновлять нужно все
|
||||
клиенты.
|
||||
3. Все новые поля payload необязательные. Отсутствие поля = значение по
|
||||
умолчанию, указанное ниже.
|
||||
4. Деньги на проводе — числа (`amount`, `limit`, `target`…), как в v1;
|
||||
внутри клиентов — целые сотые. Курс валют — **строка** десятичного числа.
|
||||
5. Даты — ISO 8601 UTC. Интервалы — `[начало, конец)`.
|
||||
6. Коды валют — ISO 4217 в верхнем регистре (`RUB`, `EUR`, `USD`, `SEK`, …).
|
||||
7. Идентификаторы записей — UUID в нижнем регистре, кроме `setting` (ключ).
|
||||
|
||||
### Возможности сервера
|
||||
|
||||
`GET /v1/capabilities` (без авторизации):
|
||||
|
||||
```json
|
||||
{
|
||||
"contract": 2,
|
||||
"entities": ["transaction","category","budget","account","transfer",
|
||||
"recurring","goal","debt","rate","setting","attachment"],
|
||||
"attachments": {"maxBytes": 10485760,
|
||||
"types": ["image/jpeg","image/png","image/heic","application/pdf"]},
|
||||
"ledgers": true
|
||||
}
|
||||
```
|
||||
|
||||
Сервер v1 отвечает 404 — клиент v2 тогда скрывает вложения и общие бюджеты и
|
||||
не отправляет новые сущности.
|
||||
|
||||
## 1. Сущности
|
||||
|
||||
Белый список сервера: `transaction`, `category`, `budget`, `account`,
|
||||
`transfer`, `recurring`, `goal`, `debt`, `rate`, `setting`, `attachment`.
|
||||
|
||||
### transaction — новые поля
|
||||
|
||||
| Поле | Тип | По умолчанию | Смысл |
|
||||
| --- | --- | --- | --- |
|
||||
| `accountId` | string? | нет | счёт; без него — «Без счёта» |
|
||||
| `currency` | string? | валюта счёта, иначе базовая | валюта суммы |
|
||||
| `tags` | [string]? | `[]` | теги, см. §6 |
|
||||
| `recurringId` | string? | нет | создана из регулярной операции |
|
||||
| `occurrence` | string? | нет | дата повторения `YYYY-MM-DD` |
|
||||
|
||||
`kindRawValue` остаётся `income` / `expense`. Перевод — **не** транзакция.
|
||||
|
||||
### account
|
||||
|
||||
```json
|
||||
{"name":"Карта","kindRawValue":"card","icon":"creditcard.fill",
|
||||
"colorName":"blue","currency":"RUB","createdAt":"…","isArchived":false,
|
||||
"sortOrder":0}
|
||||
```
|
||||
|
||||
`kindRawValue`: `cash` | `card` | `deposit` | `other` (незнакомое → `other`).
|
||||
`currency` по умолчанию — базовая.
|
||||
|
||||
### transfer
|
||||
|
||||
```json
|
||||
{"fromAccountId":"…","toAccountId":"…","amount":1000.0,"toAmount":10.5,
|
||||
"date":"…","note":""}
|
||||
```
|
||||
|
||||
`toAmount` нужен, когда валюты счетов различаются; по умолчанию = `amount`.
|
||||
`amount` — в валюте счёта-источника, `toAmount` — в валюте счёта-получателя.
|
||||
|
||||
### recurring
|
||||
|
||||
```json
|
||||
{"kindRawValue":"expense","amount":990.0,"currency":"RUB","accountId":null,
|
||||
"categoryName":"Подписки","categoryIcon":"repeat","categoryEmoji":"",
|
||||
"categoryColorName":"cyan","note":"Связь","tags":[],
|
||||
"frequency":"monthly","interval":1,"startDate":"2026-01-05T09:00:00Z",
|
||||
"endDate":null,"mode":"auto","isPaused":false,"generatedThrough":null}
|
||||
```
|
||||
|
||||
`frequency`: `daily` | `weekly` | `monthly` | `yearly`; `interval` ≥ 1;
|
||||
`mode`: `auto` (создаётся само) | `remind` (напоминание, пользователь
|
||||
подтверждает). `generatedThrough` — дата `YYYY-MM-DD` последнего
|
||||
обработанного повторения.
|
||||
|
||||
### goal
|
||||
|
||||
```json
|
||||
{"name":"Отпуск","target":150000.0,"currency":"RUB","deadline":null,
|
||||
"accountId":null,"saved":20000.0,"icon":"airplane","colorName":"sky",
|
||||
"createdAt":"…","isArchived":false}
|
||||
```
|
||||
|
||||
Прогресс = баланс счёта `accountId`, если он задан, иначе `saved`.
|
||||
|
||||
### debt
|
||||
|
||||
```json
|
||||
{"person":"Иван","direction":"owedToMe","amount":5000.0,"currency":"RUB",
|
||||
"date":"…","dueDate":null,"note":"","repaid":1000.0,"isClosed":false}
|
||||
```
|
||||
|
||||
`direction`: `iOwe` | `owedToMe`. Остаток = `amount − repaid`. Долги **не**
|
||||
входят в баланс и аналитику: деньги, которые реально ушли или пришли,
|
||||
пользователь записывает обычной операцией.
|
||||
|
||||
### rate
|
||||
|
||||
```json
|
||||
{"from":"USD","to":"RUB","rate":"92.35","date":"2026-10-31T00:00:00Z"}
|
||||
```
|
||||
|
||||
`rate` — сколько единиц `to` стоит одна единица `from`, до 6 знаков после
|
||||
точки.
|
||||
|
||||
### setting
|
||||
|
||||
`id` — ключ, payload `{"value": …}`. Сейчас ключ один: `baseCurrency`
|
||||
(строка ISO 4217) — валюта, в которой считаются общие суммы. Если записи нет,
|
||||
базовая валюта — локальная настройка устройства (поведение v1).
|
||||
|
||||
### attachment
|
||||
|
||||
```json
|
||||
{"transactionId":"…","sha256":"<64 hex>","contentType":"image/jpeg",
|
||||
"size":182345,"name":"чек.jpg","createdAt":"…"}
|
||||
```
|
||||
|
||||
Метаданные синхронизируются обычным `/v1/sync`; сами байты — через §8.
|
||||
|
||||
## 2. Валюты и курсы
|
||||
|
||||
- Валюта транзакции: `currency`, иначе валюта её счёта, иначе базовая.
|
||||
- Перевод суммы `x` из `C` в базовую `B`: при `C == B` без изменений; иначе
|
||||
берётся последняя (по `date`, затем по `updatedAt`) запись `rate` с
|
||||
`from=C,to=B` или обратная `from=B,to=C` (тогда делим). Если подходящих
|
||||
записей несколько с одной датой, прямая пара важнее обратной.
|
||||
- Результат округляется до сотых половиной вверх (от нуля). Считать через
|
||||
целые сотые и десятичную строку курса, не через `double`-накопление.
|
||||
- Нет курса → сумма **не входит** в общие итоги, клиент показывает
|
||||
предупреждение «нет курса C→B». Внутри счёта суммы складываются в валюте
|
||||
счёта без конвертации.
|
||||
- Курс для периода — последний на дату расчёта (не на дату операции).
|
||||
|
||||
## 3. Баланс (расширяет финансовые правила v1)
|
||||
|
||||
- Баланс счёта (в его валюте) = доходы − расходы с этим `accountId`
|
||||
(включая корректировки) + `toAmount` входящих переводов − `amount`
|
||||
исходящих.
|
||||
- «Без счёта» = транзакции без `accountId` (в своих валютах).
|
||||
- Общий баланс = сумма всех транзакций в базовой валюте + переводы
|
||||
(входящая часть − исходящая, каждая в базовой валюте). При одной валюте
|
||||
переводы дают ноль, и общий баланс совпадает с v1.
|
||||
- Переводы не входят в аналитику и бюджеты. Корректировки — как в v1.
|
||||
- Аналитика и бюджеты складывают суммы в базовой валюте.
|
||||
- Архивный счёт скрыт из выбора, но его баланс входит в общий.
|
||||
- Транзакции и переводы, ссылающиеся на удалённый счёт, остаются; клиент
|
||||
показывает их как «Удалённый счёт».
|
||||
|
||||
## 4. Регулярные операции
|
||||
|
||||
- Повторения считаются в календаре **UTC** от даты `startDate` (её UTC-дата
|
||||
и время суток): `daily` — каждые `interval` дней, `weekly` — каждые
|
||||
`7·interval` дней, `monthly` — каждые `interval` месяцев в тот же день
|
||||
(если дня нет — последний день месяца), `yearly` — то же с годами
|
||||
(29 февраля → 28 февраля). Повторение после `endDate` не создаётся.
|
||||
- `occurrence` = UTC-дата `YYYY-MM-DD`; дата транзакции = эта дата + время
|
||||
суток `startDate`.
|
||||
- ID транзакции **детерминирован**: UUID v5 от пространства имён
|
||||
`6f1c3f5e-8a8d-4c3a-9d2b-4b1f0e7a2c11` и строки
|
||||
`recurring:<recurringId>:<occurrence>` (нижний регистр). Два устройства
|
||||
создают одну и ту же запись — дублей нет.
|
||||
- `auto`: клиент создаёт все повторения в `(generatedThrough, сегодня UTC]`
|
||||
(с `startDate`, если `generatedThrough` пуст) и ставит
|
||||
`generatedThrough` = последнее обработанное. Удалённое пользователем
|
||||
повторение не воскрешается, т.к. `generatedThrough` уже позади. Не более
|
||||
366 повторений за раз.
|
||||
- `remind`: клиент показывает повторения из того же окна как «ожидают»;
|
||||
«Записать» создаёт транзакцию с тем же детерминированным ID, «Пропустить»
|
||||
только двигает `generatedThrough`.
|
||||
- `isPaused` — ничего не создаётся и не напоминается.
|
||||
- Часы регулярные операции не создают (их создают телефон и десктоп).
|
||||
|
||||
## 5. Цели, долги — см. §1. Цели и долги не меняют баланс.
|
||||
|
||||
## 6. Теги
|
||||
|
||||
Строки после `trim`, 1–32 символа, без учёта регистра уникальны в операции,
|
||||
не больше 10 на операцию. Порядок сохраняется. Аналитика и бюджеты тегами не
|
||||
фильтруются; список операций — фильтруется.
|
||||
|
||||
## 7. Общие бюджеты (ledgers)
|
||||
|
||||
Пространство данных (ledger) — отдельный набор записей со своим курсором.
|
||||
У каждого пользователя есть **личное** пространство (как в v1). Общее
|
||||
пространство создаёт владелец и приглашает участников по коду.
|
||||
|
||||
| Метод | Путь | Тело / ответ |
|
||||
| --- | --- | --- |
|
||||
| GET | `/v1/ledgers` | `{"ledgers":[{"id","name","role","members":[{"userId","email","role"}]}]}` — только общие |
|
||||
| POST | `/v1/ledgers` | `{"name"}` → ledger, `role:"owner"` |
|
||||
| PATCH | `/v1/ledgers/{id}` | `{"name"}` (владелец) |
|
||||
| DELETE | `/v1/ledgers/{id}` | владелец: удалить пространство со всеми записями и вложениями |
|
||||
| POST | `/v1/ledgers/{id}/invites` | → `{"code","expiresAt"}`; код из 10 символов, 7 дней, одноразовый (владелец) |
|
||||
| POST | `/v1/ledgers/join` | `{"code"}` → ledger, `role:"member"` |
|
||||
| DELETE | `/v1/ledgers/{id}/members/{userId}` | владелец удаляет участника; участник — себя (`userId` = свой) |
|
||||
|
||||
- Пространство выбирается заголовком `X-Balance-Ledger: <id>` на
|
||||
`/v1/sync` и `/v1/attachments/*`. Без заголовка — личное пространство.
|
||||
Не участник → `403 forbidden`, несуществующее → `404 not_found`.
|
||||
- Владелец не может выйти, пока есть участники (только удалить пространство).
|
||||
- Все участники равны в правах на записи (last-write-wins как в v1).
|
||||
- Клиент хранит данные пространства отдельно: scope = сервер + пользователь
|
||||
+ пространство. Переключение пространства не переносит записи. Курсор и
|
||||
очередь изменений — свои у каждого пространства.
|
||||
- Ошибки: `invalid_code` (400), `forbidden` (403), `not_found` (404),
|
||||
`owner_cannot_leave` (409). Коды приглашений хранятся на сервере только
|
||||
как хеш.
|
||||
|
||||
## 8. Вложения
|
||||
|
||||
| Метод | Путь | Смысл |
|
||||
| --- | --- | --- |
|
||||
| PUT | `/v1/attachments/{sha256}` | тело — байты файла, `Content-Type` из списка; сервер проверяет SHA-256 и размер; повтор идемпотентен (`200`, первый раз `201`) |
|
||||
| GET | `/v1/attachments/{sha256}` | байты и `Content-Type`; `404`, если в пространстве нет такого файла |
|
||||
|
||||
- Порядок клиента: сначала PUT байтов, потом запись `attachment` через sync.
|
||||
- Файлы хранятся в пространстве; один и тот же файл в двух пространствах
|
||||
хранится дважды.
|
||||
- Когда в пространстве не осталось живых записей `attachment` с этим
|
||||
`sha256`, сервер может удалить файл.
|
||||
- Ошибки: `payload_too_large` (413), `unsupported_media_type` (415),
|
||||
`hash_mismatch` (400).
|
||||
- Клиент скачивает файл лениво при просмотре и кеширует локально.
|
||||
|
||||
## 9. Совместимость с клиентами v1
|
||||
|
||||
- Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и
|
||||
общие пространства; транзакции в других валютах складывает как есть.
|
||||
- При редактировании транзакции старый клиент теряет `accountId`,
|
||||
`currency`, `tags`, `recurringId`, `occurrence`. Поэтому все клиенты
|
||||
экосистемы обновляются вместе с сервером.
|
||||
- Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются
|
||||
на v1 до своего обновления.
|
||||
+30
-4
@@ -11,14 +11,16 @@
|
||||
|
||||
## Общие сущности
|
||||
|
||||
Сервер должен различать как минимум следующие логические типы синхронизируемых данных, совместимые с клиентами Balance:
|
||||
Сервер принимает сущности контракта v2 (`docs/CONTRACT_V2.md` §1):
|
||||
|
||||
```text
|
||||
transaction
|
||||
category
|
||||
budget
|
||||
transaction category budget account transfer recurring
|
||||
goal debt rate setting attachment
|
||||
```
|
||||
|
||||
Payload хранится как непрозрачный JSON; сервер читает из него только `sha256`
|
||||
записей `attachment` для сборки мусора файлов. Ниже — сущности v1.
|
||||
|
||||
### Transaction
|
||||
|
||||
Логически содержит:
|
||||
@@ -97,6 +99,30 @@ payload
|
||||
7. Timestamps должны храниться в UTC.
|
||||
8. Enum/string values должны быть совместимы с iOS и Android.
|
||||
|
||||
## Таблицы контракта v2
|
||||
|
||||
Таблицы v1 (`users`, `refresh_tokens`, `records`, `changes`) не меняются. Миграция
|
||||
только добавляет таблицы через `CREATE TABLE IF NOT EXISTS`, поэтому существующая
|
||||
база (в том числе Go v1) открывается без переписывания строк и `sequence`.
|
||||
|
||||
| Таблица | Ключ | Назначение |
|
||||
| --- | --- | --- |
|
||||
| `ledgers` | `id` (UUID) | имя, `owner_id`, `created_at` |
|
||||
| `ledger_members` | `(ledger_id, user_id)`, индекс `user_id` | `role` = `owner`/`member`, `joined_at` |
|
||||
| `ledger_invites` | `code_hash` (SHA-256 кода), индекс `ledger_id` | `expires_at`, `used_at`, `used_by`; код в открытом виде не хранится |
|
||||
| `ledger_records` | `(ledger_id, entity, record_id)` | текущее состояние записей пространства |
|
||||
| `ledger_changes` | `sequence` AUTOINCREMENT, индекс `(ledger_id, sequence)` | поток изменений пространства |
|
||||
| `attachment_blobs` | `(space, sha256)` | метаданные файлов: `content_type`, `size`, `uploaded_at`, `referenced` |
|
||||
|
||||
`space` в `attachment_blobs` — `user:<userId>` или `ledger:<ledgerId>`. Все таблицы
|
||||
ledger ссылаются на `ledgers(id) ON DELETE CASCADE`; строки `attachment_blobs`
|
||||
пространства удаляются в той же транзакции, что и ledger.
|
||||
|
||||
Файлы лежат в `BALANCE_ATTACHMENTS_DIR` (по умолчанию `attachments` рядом с
|
||||
файлом БД): `users/<userId>/<sha[0:2]>/<sha>` и
|
||||
`ledgers/<ledgerId>/<sha[0:2]>/<sha>`. Каталоги создаются с правами `0750`.
|
||||
Один и тот же файл в двух пространствах хранится дважды.
|
||||
|
||||
## Миграции
|
||||
|
||||
Schema changes должны быть backward-compatible с активными версиями клиентов либо сопровождаться versioned API/миграцией.
|
||||
|
||||
@@ -35,6 +35,7 @@ for remote access; pass `X-Forwarded-Proto: https` to enable Secure session cook
|
||||
| `BALANCE_REFRESH_TTL` | `720h` | Go-style duration for refresh tokens |
|
||||
| `BALANCE_ALLOW_REGISTRATION` | `true` | Enable creation of accounts |
|
||||
| `BALANCE_CORS_ORIGINS` | empty | Comma-separated allowed browser origins |
|
||||
| `BALANCE_ATTACHMENTS_DIR` | `attachments` beside the database | Attachment files; Docker uses `/data/attachments` inside the same volume. Back it up with the database |
|
||||
|
||||
Inject secrets through the deployment environment or `.env`; never put them in
|
||||
images or source control. Compose preserves the existing named `balance-data`
|
||||
|
||||
@@ -51,6 +51,25 @@ HTTP следует оставлять только для controlled local deve
|
||||
- JSON size;
|
||||
- cursor/sequence bounds.
|
||||
|
||||
## Общие пространства и вложения (контракт v2)
|
||||
|
||||
- Доступ к ledger проверяется на каждый запрос по `ledger_members`; `X-Balance-Ledger`
|
||||
не участника → `403`, неизвестный → `404`. Управлять пространством и выдавать
|
||||
приглашения может только владелец.
|
||||
- Коды приглашений — 10 случайных символов из 32 (50 бит, `OsRng`), одноразовые,
|
||||
7 дней; в БД хранится только SHA-256. `/v1/ledgers/join` ограничен общим
|
||||
лимитом 30 запросов/мин на IP.
|
||||
- Вложения проверяются до записи на диск: формат хеша, тип из белого списка,
|
||||
размер ≤ 10 MiB и SHA-256 тела. Файл пишется во временный файл и
|
||||
переименовывается. Тело больше 2 MiB читается только после проверки
|
||||
access token. Путь к файлу строится из проверенного hex-хеша и id
|
||||
пространства (небезопасный id заменяется его хешем), поэтому значения клиента
|
||||
не становятся компонентами пути.
|
||||
- Файлы отдаются с `Content-Disposition: attachment`, `X-Content-Type-Options:
|
||||
nosniff`, `Cache-Control: no-store` и не отображаются в origin веб-клиента.
|
||||
- Каталог вложений содержит финансовые документы: права `0750`, резервные
|
||||
копии — вместе с БД.
|
||||
|
||||
## Logging
|
||||
|
||||
Безопасно логировать:
|
||||
|
||||
@@ -131,6 +131,35 @@ Server не должен предполагать, что все изменен
|
||||
|
||||
Нельзя молча трактовать неизвестный/просроченный cursor как актуальный.
|
||||
|
||||
## Пространства (контракт v2)
|
||||
|
||||
`POST /v1/sync` работает в одном пространстве данных:
|
||||
|
||||
- без `X-Balance-Ledger` — личное пространство пользователя, таблицы
|
||||
`records`/`changes`, поведение v1 без изменений;
|
||||
- с `X-Balance-Ledger: <id>` — общее пространство, таблицы
|
||||
`ledger_records`/`ledger_changes`. Доступ проверяется на каждый запрос.
|
||||
|
||||
У общего пространства свой поток изменений: `sequence` берётся из отдельного
|
||||
AUTOINCREMENT `ledger_changes`, а pull фильтрует по `ledger_id`. Курсор личного
|
||||
пространства и курсор каждого ledger независимы, клиент хранит их раздельно.
|
||||
Все участники пишут в одно пространство с тем же last-write-wins по
|
||||
`updatedAt`; идемпотентность, пагинация (500), лимиты и транзакционность те же.
|
||||
|
||||
Удаление пространства физически удаляет его записи и историю изменений.
|
||||
Удалённый участник теряет доступ (`403`), записи остаются в пространстве.
|
||||
|
||||
## Вложения и сборка мусора
|
||||
|
||||
Байты вложений загружаются отдельно (`PUT /v1/attachments/{sha256}`) до записи
|
||||
`attachment` в sync. Файл пространства считается мусором, когда ни одна живая
|
||||
(`deleted=0`) запись `attachment` этого пространства не ссылается на его
|
||||
`sha256` (сравнение без учёта регистра). Сборка выполняется в той же транзакции,
|
||||
что и sync-запрос, содержащий изменения `attachment`, и при каждом PUT.
|
||||
Файл, ни разу не получивший ссылки, хранится не меньше 24 часов с последней
|
||||
загрузки, чтобы не удалить байты, чья запись ещё не синхронизирована; повторная
|
||||
загрузка перезапускает этот срок.
|
||||
|
||||
## Security
|
||||
|
||||
Sync endpoint всегда ограничен текущим authenticated user.
|
||||
|
||||
Reference in new issue
Block a user