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:
wtandClaude Opus 5.5 committed 2026-10-31 23:30:12 +07:00
1 parent 10272f3851
commit dfb4fe4e1a
17 files changed
+2496 -56

No files matched your search

+73 -4
View File
@@ -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`.
## Ошибки
Рекомендуемый контракт: