8 Commits
Author SHA1 Message Date
wt e32169aed1 Merge pull request 'Версия 1.1.0: контракт v2 — общие бюджеты, вложения, новые сущности' (#3) from develop into main
CI / Rust (fmt / clippy / test / build) (push) Successful in 1m31s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 2m10s
Docker publish / Build and push image (push) Successful in 11m43s
Reviewed-on: #3
2026-10-31 19:18:49 +00:00
wtandClaude Opus 5.5 effe959820 Bump version to 1.1.0
CI / Rust (fmt / clippy / test / build) (push) Successful in 1m40s
CI / Rust (fmt / clippy / test / build) (pull_request) Successful in 1m23s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 2m21s
CI / HTTP/JSON reference + SQLite persistence (pull_request) Successful in 3m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:15:47 +07:00
wtandClaude Opus 5.5 7fbe86a943 Contract v2: clarify ids, rates, recurring, ledger and attachment details
CI / Rust (fmt / clippy / test / build) (push) Successful in 1m4s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 1m36s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 23:51:38 +07:00
wtandClaude Opus 5.5 dfb4fe4e1a 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>
2026-10-31 23:30:12 +07:00
wt f97cdf0f2e Merge pull request 'Rust-only server, multi-arch Docker image, CI and docs' (#2) from develop into main
CI / Rust (fmt / clippy / test / build) (push) Successful in 53s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 1m30s
Reviewed-on: #2
2026-10-30 09:35:03 +00:00
wtandClaude Opus 5.5 10272f3851 Remove the Go reference server; keep only Rust
CI / Rust (fmt / clippy / test / build) (push) Successful in 1m41s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 1m16s
CI / Rust (fmt / clippy / test / build) (pull_request) Successful in 47s
CI / HTTP/JSON reference + SQLite persistence (pull_request) Successful in 1m34s
The Go implementation is preserved in the v1.0.0 tag. Its observable
behaviour stays the API reference: responses recorded from the Go server
live in tools/compatibility_expected.txt, and tools/compatibility.py now
compares the Rust server against them (--update re-records after an
intentional change). The Go SQLite schema is frozen in
tests/fixtures/go_schema_v1.sql for the existing-database test.

Move the web client from internal/httpapi/web to web/, drop the Go CI job
and Go version pin, and update README, AGENTS.md and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:27:33 +07:00
wtandClaude Opus 5.5 16dd2a3b3c docs: add repository cover and avatar images
CI / Go (test / vet / build) (push) Successful in 39s
CI / Rust (fmt / clippy / test / build) (push) Successful in 54s
CI / Go ↔ Rust differential + SQLite takeover (push) Successful in 1m55s
Add a 1280x640 cover shown at the top of the README, a square variant and
a text-free repository avatar that stays legible at small sizes. SVG
sources are kept next to the rendered PNGs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:48:13 +07:00
wt 2b34c2e318 Merge pull request 'Develop' (#1) from develop into main
CI / Go (test / vet / build) (push) Successful in 29s
CI / Rust (fmt / clippy / test / build) (push) Successful in 1m0s
CI / Go ↔ Rust differential + SQLite takeover (push) Successful in 1m37s
Docker publish / Build and push image (push) Failing after 10s
Reviewed-on: #1
2026-10-30 06:23:10 +00:00
48 changed files with 3617 additions and 1491 deletions

No files matched your search

+6 -46
View File
@@ -9,10 +9,6 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
# Pin the toolchain to the declared MSRV so CI matches production. # Pin the toolchain to the declared MSRV so CI matches production.
RUST_TOOLCHAIN: 1.90.0 RUST_TOOLCHAIN: 1.90.0
# The differential check compares Rust against net/http behaviour, which
# changes between Go releases (e.g. ServeMux redirects 301 -> 307), so the
# reference must be built with the same Go the Rust port mirrors.
GO_VERSION: "1.27.3"
jobs: jobs:
rust: rust:
@@ -58,39 +54,10 @@ jobs:
path: target/release/balance-server path: target/release/balance-server
if-no-files-found: error if-no-files-found: error
go:
name: Go (test / vet / build)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Test
run: go test ./...
- name: Vet
run: go vet ./...
- name: Build
run: go build ./...
compatibility: compatibility:
name: Go ↔ Rust differential + SQLite takeover name: HTTP/JSON reference + SQLite persistence
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [rust, go] needs: [rust]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -100,11 +67,6 @@ jobs:
with: with:
toolchain: ${{ env.RUST_TOOLCHAIN }} toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install Python - name: Install Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@@ -121,10 +83,8 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-
- name: Build Rust (debug) and Go reference - name: Build Rust (debug)
run: | run: cargo build --locked
cargo build --locked
go build -o target/integration/go-server ./cmd/balance-server
- name: Run differential compatibility scenarios - name: Compare against the recorded Go reference
run: python3 tools/compatibility.py --rust-binary target/debug/balance-server run: python3 tools/compatibility.py --binary target/debug/balance-server
+13 -27
View File
@@ -8,28 +8,15 @@
## Быстрая карта проекта ## Быстрая карта проекта
Production-сервер написан на Rust 1.90+. Go 1.25.0+ сохранён как Сервер написан на Rust 1.90+. Исходная Go-реализация удалена после v1.0.0
эталон совместимости API/SQLite и вариант отката. (её исходники остались в теге `v1.0.0`), но её поведение остаётся эталоном API:
ответы Go записаны в `tools/compatibility_expected.txt`, а исходная SQLite-схема
Go — в `tests/fixtures/go_schema_v1.sql`. Оба файла не правь вручную.
Основные Rust-модули: `src/main.rs`, `runtime.rs`, `config.rs`, Основные Rust-модули: `src/main.rs`, `runtime.rs`, `config.rs`,
`database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`. `database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`,
Веб-клиент из `internal/httpapi/web` встроен в Rust executable. `ledgers.rs`, `attachments.rs` (контракт v2, `docs/CONTRACT_V2.md`).
Веб-клиент из `web/` встроен в Rust executable.
Основные Go-файлы:
```text
- internal/database/database.go
- internal/config/config.go
- internal/auth/tokens.go
- internal/auth/password.go
- internal/httpapi/auth_handlers.go
- internal/httpapi/server.go
- internal/httpapi/sync_handler.go
- internal/httpapi/web.go
- internal/httpapi/server_test.go
```
Количество Go-файлов: **9**.
Обнаруженные технологии/интеграции: SQLite, Docker. Обнаруженные технологии/интеграции: SQLite, Docker.
@@ -106,12 +93,13 @@ cargo fmt --check
cargo test --locked cargo test --locked
cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets -- -D warnings
cargo build --locked --release cargo build --locked --release
python3 tools/compatibility.py --rust-binary target/release/balance-server python3 tools/compatibility.py --binary target/release/balance-server
go test ./...
go vet ./...
go build ./...
``` ```
Если `tools/compatibility.py` нашёл расхождение, это изменение видно клиентам.
Перезаписывай эталон (`--update`) только для намеренного изменения API и
после обновления документации.
Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI. Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI.
### 8. Документация ### 8. Документация
@@ -130,8 +118,6 @@ go build ./...
- [ ] Sync остаётся идемпотентным. - [ ] Sync остаётся идемпотентным.
- [ ] DB migration учтена. - [ ] DB migration учтена.
- [ ] Concurrent requests безопасны. - [ ] Concurrent requests безопасны.
- [ ] `go test ./...` проходит.
- [ ] Rust tests, fmt, clippy и release build проходят. - [ ] Rust tests, fmt, clippy и release build проходят.
- [ ] Differential HTTP/JSON и Go ↔ Rust SQLite takeover проходят. - [ ] `tools/compatibility.py` совпадает с записанным эталоном Go.
- [ ] `go vet ./...` проходит.
- [ ] Документация обновлена. - [ ] Документация обновлена.
Generated
+1 -1
View File
@@ -89,7 +89,7 @@ dependencies = [
[[package]] [[package]]
name = "balance-server" name = "balance-server"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum", "axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "balance-server" name = "balance-server"
version = "1.0.0" version = "1.1.0"
edition = "2024" edition = "2024"
rust-version = "1.90" rust-version = "1.90"
+1 -1
View File
@@ -23,7 +23,7 @@ RUN set -eu; \
WORKDIR /src WORKDIR /src
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY src ./src COPY src ./src
COPY internal/httpapi/web ./internal/httpapi/web COPY web ./web
RUN . /cross.env \ RUN . /cross.env \
&& cargo build --locked --release --target "$RUST_TARGET" \ && cargo build --locked --release --target "$RUST_TARGET" \
&& cp "target/$RUST_TARGET/release/balance-server" /balance-server && cp "target/$RUST_TARGET/release/balance-server" /balance-server
+27 -17
View File
@@ -1,5 +1,7 @@
# Balance Server # Balance Server
![Balance Server — self-hosted sync and accounts for Balance](docs/cover.png)
Self-hosted synchronization, account and web server for the Balance iOS, macOS, watchOS and Android applications. It is a single Rust process with a bundled SQLite engine and a complete browser client, and runs on Linux, Windows and macOS. Self-hosted synchronization, account and web server for the Balance iOS, macOS, watchOS and Android applications. It is a single Rust process with a bundled SQLite engine and a complete browser client, and runs on Linux, Windows and macOS.
## Quick start with Docker ## Quick start with Docker
@@ -43,8 +45,7 @@ $env:BALANCE_DB_PATH = ".\data\balance.db"
For another platform, build on that platform or install its Rust target and C toolchain For another platform, build on that platform or install its Rust target and C toolchain
for bundled SQLite. `cargo build --locked --release --target <target>` requires the for bundled SQLite. `cargo build --locked --release --target <target>` requires the
matching linker and C compiler. The retained Go implementation is a compatibility matching linker and C compiler.
reference and rollback option: `go build -o balance-server-go ./cmd/balance-server`.
After starting the binary, open `http://localhost:8080` in a browser. The web assets are embedded into the executable, so there is no separate frontend build, Node.js process or static directory to deploy. After starting the binary, open `http://localhost:8080` in a browser. The web assets are embedded into the executable, so there is no separate frontend build, Node.js process or static directory to deploy.
@@ -75,6 +76,7 @@ The browser session uses `HttpOnly`, `SameSite=Strict` cookies. Native applicati
| `BALANCE_ACCESS_TTL` | `15m` | Access-token lifetime | | `BALANCE_ACCESS_TTL` | `15m` | Access-token lifetime |
| `BALANCE_REFRESH_TTL` | `720h` | Refresh-token lifetime | | `BALANCE_REFRESH_TTL` | `720h` | Refresh-token lifetime |
| `BALANCE_CORS_ORIGINS` | empty | Comma-separated browser origins; native apps do not need CORS | | `BALANCE_CORS_ORIGINS` | empty | Comma-separated browser origins; native apps do not need CORS |
| `BALANCE_ATTACHMENTS_DIR` | `attachments` next to `BALANCE_DB_PATH` | Directory for attachment files (receipt photos); Docker: `/data/attachments` |
## HTTPS ## HTTPS
@@ -98,23 +100,33 @@ Then enter `https://balance.example.com` in each application's Settings → Serv
| `POST` | `/v1/auth/refresh` | Rotate session tokens | | `POST` | `/v1/auth/refresh` | Rotate session tokens |
| `POST` | `/v1/auth/logout` | Revoke refresh token | | `POST` | `/v1/auth/logout` | Revoke refresh token |
| `GET` | `/v1/me` | Current account | | `GET` | `/v1/me` | Current account |
| `POST` | `/v1/sync` | Push and pull changes | | `POST` | `/v1/sync` | Push and pull changes (`X-Balance-Ledger` selects a shared ledger) |
| `GET` | `/v1/capabilities` | Contract version and features (no authorization) |
| `GET`, `POST` | `/v1/ledgers` | List / create shared ledgers |
| `PATCH`, `DELETE` | `/v1/ledgers/{id}` | Rename / delete a ledger (owner) |
| `POST` | `/v1/ledgers/{id}/invites` | One-time invite code (owner) |
| `POST` | `/v1/ledgers/join` | Join a ledger by code |
| `DELETE` | `/v1/ledgers/{id}/members/{userId}` | Remove a member or leave |
| `PUT`, `GET` | `/v1/attachments/{sha256}` | Upload / download attachment bytes |
The sync endpoint accepts transactions, custom categories and monthly budgets. The server isolates records by account, stores an append-only change cursor, and resolves competing edits with last-write-wins timestamps. Current Apple and Android clients use push-only requests followed by paginated pulls; update the server and applications together. The server implements ecosystem contract v2 ([docs/CONTRACT_V2.md](docs/CONTRACT_V2.md)).
The sync endpoint accepts transactions, categories, budgets, accounts, transfers,
recurring operations, goals, debts, exchange rates, settings and attachment metadata. The server isolates records by account, stores an append-only change cursor, and resolves competing edits with last-write-wins timestamps. Current Apple and Android clients use push-only requests followed by paginated pulls; update the server and applications together.
## Operational notes ## Operational notes
- Registration can be disabled after the required accounts have been created. - Registration can be disabled after the required accounts have been created.
- Passwords are stored as salted Argon2id hashes. Refresh tokens are stored only as SHA-256 hashes. - Passwords are stored as salted Argon2id hashes. Refresh tokens are stored only as SHA-256 hashes.
- Back up the SQLite database and its `-wal`/`-shm` files together, or stop the server before copying the database. - Back up the SQLite database and its `-wal`/`-shm` files together with the attachments directory, or stop the server before copying them.
- Changing `BALANCE_JWT_SECRET` invalidates access tokens. Existing refresh sessions remain valid and will receive new access tokens after refresh. - Changing `BALANCE_JWT_SECRET` invalidates access tokens. Existing refresh sessions remain valid and will receive new access tokens after refresh.
- The in-process authentication limiter allows 30 authentication requests per IP per minute. Use a reverse proxy for stronger public rate limiting and request logging. - The in-process authentication limiter allows 30 authentication requests per IP per minute. Use a reverse proxy for stronger public rate limiting and request logging.
## Compatibility and migration ## Compatibility and migration
The Rust server retains the HTTP/JSON API, embedded web client, environment The Rust server replaced the original Go implementation and retains its HTTP/JSON
variables, SQLite schema, Argon2id password hashes, JWT format, refresh sessions API, embedded web client, environment variables, SQLite schema, Argon2id password
and sync cursor semantics of the Go implementation. Docker Compose still maps hashes, JWT format, refresh sessions and sync cursor semantics. The Go sources were
removed after v1.0.0; that tag still contains them for reference or rollback. Docker Compose still maps
host port **8123** to container port **8080** and keeps `/data` in `balance-data`. host port **8123** to container port **8080** and keeps `/data` in `balance-data`.
Direct binary execution defaults to port 8080. Direct binary execution defaults to port 8080.
@@ -124,8 +136,7 @@ against the same `BALANCE_DB_PATH`; do not run both writers simultaneously.
Existing accounts, access/refresh tokens and saved device cursors remain usable. Existing accounts, access/refresh tokens and saved device cursors remain usable.
See [deployment and rollback](docs/DEPLOYMENT.md) for the full procedure. See [deployment and rollback](docs/DEPLOYMENT.md) for the full procedure.
Verify the implementations locally with Go 1.27.3 (the version CI uses for the Verify the server locally with Rust and Python 3:
differential check), Rust and Python 3:
```sh ```sh
cargo test --locked cargo test --locked
@@ -133,12 +144,11 @@ cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets -- -D warnings
cargo build --locked cargo build --locked
python3 tools/compatibility.py python3 tools/compatibility.py
go test ./...
go vet ./...
go build ./...
``` ```
The Python standard-library suite builds the Go reference, starts both binaries The Python standard-library suite starts the server on temporary loopback ports
on temporary loopback ports and databases, compares API responses and assets, and databases, compares its API responses and assets with the responses recorded
and tests SQLite takeover and rollback in both directions. To use release or from the original Go server (`tools/compatibility_expected.txt`), and checks that
prebuilt executables, pass `--rust-binary PATH` and optionally `--go-binary PATH`. SQLite data survives restarts. To test a release or prebuilt executable, pass
`--binary PATH`. After an intentional API change, review the difference and
re-record the reference with `--update`.
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"balance-server/internal/config"
"balance-server/internal/database"
"balance-server/internal/httpapi"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("configuration error", "error", err)
os.Exit(1)
}
db, err := database.Open(cfg.DatabasePath)
if err != nil {
logger.Error("database error", "error", err)
os.Exit(1)
}
defer db.Close()
server := &http.Server{
Addr: cfg.Address,
Handler: httpapi.New(cfg, db, logger),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
logger.Info("Balance server started", "address", cfg.Address, "database", cfg.DatabasePath)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
}
+73 -4
View File
@@ -82,14 +82,21 @@ POST /v1/sync
} }
``` ```
Поддерживаемые entity: Поддерживаемые entity (контракт v2, `docs/CONTRACT_V2.md` §1):
```text ```text
transaction transaction category budget account transfer recurring
category goal debt rate setting attachment
budget
``` ```
Незнакомая entity отклоняется `400 invalid_change`; текст сообщения
(`entity must be transaction, category, or budget`) сохранён из v1, потому что
он записан в эталоне `tools/compatibility_expected.txt`. Payload сервер хранит
как есть и не валидирует по сущности.
Заголовок `X-Balance-Ledger: <id>` выбирает общее пространство (см. ниже). Без
заголовка (или с пустым значением) — личное пространство, как в v1.
## Push ## Push
Client отправляет локальные изменения. Client отправляет локальные изменения.
@@ -112,6 +119,68 @@ Server возвращает изменения после этого cursor, в
Если существует pagination, ответ должен содержать понятный признак продолжения и новый cursor/sequence. Если существует 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`.
## Ошибки ## Ошибки
Рекомендуемый контракт: Рекомендуемый контракт:
+2 -9
View File
@@ -23,16 +23,9 @@ 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. Статические файлы из `internal/httpapi/web` встроены при компиляции. Go-код сохранён как эталон совместимости и вариант rollback. 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-реализации: Исходная Go-реализация удалена после v1.0.0 (исходники остались в теге `v1.0.0`). Её наблюдаемое поведение зафиксировано в `tools/compatibility_expected.txt`, а SQLite-схема — в `tests/fixtures/go_schema_v1.sql`; Rust сверяется с обоими в CI.
```text
- internal/auth
- internal/config
- internal/database
- internal/httpapi
```
Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API. Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API.
+287
View File
@@ -0,0 +1,287 @@
# Контракт экосистемы 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`, где `id` —
ключ). Сервер сравнивает `id` как есть, с учётом регистра, поэтому клиент
хранит `id` полученной записи в точности как пришёл и отправляет его так же
(старые клиенты могли создавать UUID в верхнем регистре).
### Возможности сервера
`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`).
Клиент v2 записывает `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` без изменений; иначе
берётся запись `rate` с `from=C,to=B` или обратная `from=B,to=C` (тогда
делим). Выбор: самая поздняя `date` не позже даты расчёта (курсы с будущей
датой не используются); при равной дате прямая пара важнее обратной; затем
более поздний `updatedAt`.
- Результат округляется до сотых половиной вверх (от нуля). Считать через
целые сотые и десятичную строку курса, не через `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` сравнивается по UTC-дате и включается:
повторение в день `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` только через подряд обработанные повторения (если
записать более позднее раньше, более раннее остаётся ожидать).
«Пропустить» двигает `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`.
- Владелец не может выйти (`409 owner_cannot_leave`), даже из пустого
пространства — только удалить его.
- Ответы: создание пространства и приглашения — `201`; вход по коду и
переименование — `200`; удаление пространства и участника — `204`.
Повторный вход участника возвращает пространство и не тратит код.
`/v1/ledgers/join` ограничен по частоте вместе со входом (30 запросов в
минуту с IP).
- Клиент, получивший `403`/`404` для выбранного пространства, может сам
вернуться в личное (Flutter) или показать ошибку и предложить переключиться
(часы).
- Все участники равны в правах на записи (last-write-wins как в v1).
- Клиент хранит данные пространства отдельно: scope = сервер + пользователь
+ пространство. Переключение пространства не переносит записи. Курсор и
очередь изменений — свои у каждого пространства.
- Ошибки: `invalid_code` (400), `invalid_name` (400: пустое после `trim`,
длиннее 100 символов или с управляющими символами), `invalid_json` (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.
- Файлы хранятся в пространстве; один и тот же файл в двух пространствах
хранится дважды.
- Ответ PUT: `{"sha256","size","contentType"}`. GET отдаёт
`Content-Disposition: attachment`.
- Когда в пространстве не осталось живых записей `attachment` с этим
`sha256`, сервер может удалить файл. Файл, на который ещё не ссылалась ни
одна запись, хранится не меньше 24 часов после загрузки (клиент загружает
байты раньше записи).
- Лимит тела 10 МиБ действует только для загрузки вложения; остальные
запросы — 2 МиБ, как в v1.
- Ошибки: `payload_too_large` (413), `unsupported_media_type` (415),
`hash_mismatch` (400, в том числе неверный формат хеша в пути).
- Клиент скачивает файл лениво при просмотре и кеширует локально.
- Сообщение ошибки для незнакомой сущности на сервере осталось как в v1
(«entity must be transaction, category, or budget») ради совместимости с
записанными ответами; код `invalid_change` верный.
## 9. Совместимость с клиентами v1
- Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и
общие пространства; транзакции в других валютах складывает как есть.
- При редактировании транзакции старый клиент теряет `accountId`,
`currency`, `tags`, `recurringId`, `occurrence`. Поэтому все клиенты
экосистемы обновляются вместе с сервером.
- Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются
на v1 до своего обновления.
+35 -24
View File
@@ -2,38 +2,25 @@
Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ. Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ.
## Найденные определения моделей/schema ## Где определена schema
- internal/database/database.go - `src/database.rs` — создание и открытие схемы SQLite;
- internal/database/database.go - `tests/fixtures/go_schema_v1.sql` — исходная схема Go-сервера v1.0.0; Rust обязан открывать такие базы без изменений;
- internal/database/database.go - `src/sync.rs` — валидация и сериализация синхронизируемых записей;
- internal/config/config.go - `src/auth.rs` — пользователи, пароли и refresh-сессии.
- internal/auth/tokens.go
- internal/auth/tokens.go
- internal/httpapi/auth_handlers.go
- internal/httpapi/auth_handlers.go
- internal/httpapi/auth_handlers.go
- internal/httpapi/server.go
- internal/httpapi/server.go
- internal/httpapi/server.go
- internal/httpapi/sync_handler.go
- internal/httpapi/sync_handler.go
- internal/httpapi/sync_handler.go
- internal/httpapi/web.go
- internal/httpapi/server_test.go
- internal/httpapi/server_test.go
- internal/httpapi/server_test.go
## Общие сущности ## Общие сущности
Сервер должен различать как минимум следующие логические типы синхронизируемых данных, совместимые с клиентами Balance: Сервер принимает сущности контракта v2 (`docs/CONTRACT_V2.md` §1):
```text ```text
transaction transaction category budget account transfer recurring
category goal debt rate setting attachment
budget
``` ```
Payload хранится как непрозрачный JSON; сервер читает из него только `sha256`
записей `attachment` для сборки мусора файлов. Ниже — сущности v1.
### Transaction ### Transaction
Логически содержит: Логически содержит:
@@ -112,6 +99,30 @@ payload
7. Timestamps должны храниться в UTC. 7. Timestamps должны храниться в UTC.
8. Enum/string values должны быть совместимы с iOS и Android. 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/миграцией. Schema changes должны быть backward-compatible с активными версиями клиентов либо сопровождаться versioned API/миграцией.
+12 -11
View File
@@ -3,9 +3,9 @@
## Runtime and configuration ## Runtime and configuration
The production executable is the Rust server, built with `cargo build --locked The production executable is the Rust server, built with `cargo build --locked
--release`. SQLite is bundled into the executable. The Go implementation remains --release`. SQLite is bundled into the executable. The original Go implementation
in `cmd/balance-server` and `internal/` as a compatibility reference and rollback was removed after v1.0.0; the `v1.0.0` tag still contains its sources for the
option; build it with `go build -o balance-server-go ./cmd/balance-server`. migration and rollback below (`go build -o balance-server-go ./cmd/balance-server`).
The Dockerfile uses a Rust 1.90 Debian Bookworm builder and a Debian Bookworm slim The Dockerfile uses a Rust 1.90 Debian Bookworm builder and a Debian Bookworm slim
runtime, running as the unprivileged `balance` user. The image embeds the existing runtime, running as the unprivileged `balance` user. The image embeds the existing
@@ -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_REFRESH_TTL` | `720h` | Go-style duration for refresh tokens |
| `BALANCE_ALLOW_REGISTRATION` | `true` | Enable creation of accounts | | `BALANCE_ALLOW_REGISTRATION` | `true` | Enable creation of accounts |
| `BALANCE_CORS_ORIGINS` | empty | Comma-separated allowed browser origins | | `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 Inject secrets through the deployment environment or `.env`; never put them in
images or source control. Compose preserves the existing named `balance-data` images or source control. Compose preserves the existing named `balance-data`
@@ -65,8 +66,9 @@ required. Do not start both implementations against the same database at once.
Stop Rust first. Start the retained Go executable/image with the same current Stop Rust first. Start the retained Go executable/image with the same current
database directory and secret. Rust-written accounts, password hashes, refresh database directory and secret. Rust-written accounts, password hashes, refresh
sessions, records, tombstones and cursors remain readable by Go. Check health, sessions, records, tombstones and cursors remain readable by Go. Check health,
login, refresh and sync again. The compatibility suite exercises both directions, login, refresh and sync again. Before the Go sources were removed, the
including a second takeover back to the original implementation. compatibility suite verified takeover and rollback in both directions; Rust still
writes the same schema (`tests/fixtures/go_schema_v1.sql`).
For database corruption or an unrelated failed migration, stop all writers before For database corruption or an unrelated failed migration, stop all writers before
restoring the complete backup directory. Restoring an older backup discards restoring the complete backup directory. Restoring an older backup discards
@@ -77,12 +79,11 @@ an active database or delete a Compose volume as part of an upgrade.
## Validation and operations ## Validation and operations
Before release run `cargo test --locked`, `cargo build --locked --release`, Before release run `cargo test --locked`, `cargo build --locked --release`,
`python3 tools/compatibility.py --rust-binary target/release/balance-server`, and and `python3 tools/compatibility.py --binary target/release/balance-server`.
the retained reference checks `go test ./...`, `go vet ./...`, `go build ./...`. The suite uses temporary databases and loopback listeners, compares
The differential suite uses temporary databases and loopback listeners, compares API/errors/security headers/assets with the recorded Go reference, tests browser
API/errors/security headers/assets, tests browser cookies, pagination, LWW, cookies, pagination, LWW, tombstones, concurrent pushes, token expiration, refresh
tombstones, concurrent pushes, token expiration, refresh rotation, rate limiting, rotation, rate limiting, and SQLite persistence across restarts.
and shared-database takeover/rollback in both directions.
Keep regular encrypted backups with retention and restore drills. Monitor HTTP Keep regular encrypted backups with retention and restore drills. Monitor HTTP
5xx, sync errors, authentication failures, latency, database/change-log growth 5xx, sync errors, authentication failures, latency, database/change-log growth
+8 -9
View File
@@ -2,28 +2,26 @@
## Требования ## Требования
Production-сервер использует Rust 1.90+ и bundled SQLite. Go 1.25+ нужен для сохранённой эталонной реализации; differential-проверки (`tools/compatibility.py`) требуют Go 1.27.3, как в CI: поведение `net/http` (например, код редиректа ServeMux 301/307) зависит от версии Go. Python 3 используется без сторонних пакетов. Сервер использует Rust 1.90+ и bundled SQLite. Python 3 нужен для `tools/compatibility.py` и используется без сторонних пакетов. Go больше не нужен.
Проверить локальную версию: Проверить локальную версию:
```bash ```bash
rustc --version rustc --version
cargo --version cargo --version
go version python3 --version
``` ```
## Установка зависимостей ## Установка зависимостей
```bash ```bash
cargo fetch --locked cargo fetch --locked
go mod download
``` ```
## Сборка ## Сборка
```bash ```bash
cargo build --locked cargo build --locked
go build ./...
``` ```
## Тесты ## Тесты
@@ -32,13 +30,15 @@ go build ./...
cargo test --locked cargo test --locked
cargo build --locked cargo build --locked
python3 tools/compatibility.py python3 tools/compatibility.py
go test ./...
``` ```
С race detector: `tools/compatibility.py` запускает сервер на временных портах и базах и сравнивает
ответы API и статику с ответами исходного Go-сервера, записанными в
`tools/compatibility_expected.txt`. Расхождение означает изменение, видимое
клиентам. Если изменение намеренное, проверьте diff и перезапишите эталон:
```bash ```bash
go test -race ./... python3 tools/compatibility.py --update
``` ```
## Static checks ## Static checks
@@ -46,7 +46,6 @@ go test -race ./...
```bash ```bash
cargo fmt --check cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets -- -D warnings
go vet ./...
``` ```
Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline. Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline.
@@ -89,7 +88,7 @@ cargo clippy --locked --all-targets -- -D warnings
↓ ↓
cargo build --locked --release cargo build --locked --release
↓ ↓
python3 tools/compatibility.py --rust-binary target/release/balance-server python3 tools/compatibility.py --binary target/release/balance-server
↓ ↓
container/package container/package
↓ ↓
+19
View File
@@ -51,6 +51,25 @@ HTTP следует оставлять только для controlled local deve
- JSON size; - JSON size;
- cursor/sequence bounds. - 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 ## Logging
Безопасно логировать: Безопасно логировать:
+29
View File
@@ -131,6 +131,35 @@ Server не должен предполагать, что все изменен
Нельзя молча трактовать неизвестный/просроченный cursor как актуальный. Нельзя молча трактовать неизвестный/просроченный 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 ## Security
Sync endpoint всегда ограничен текущим authenticated user. Sync endpoint всегда ограничен текущим authenticated user.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+34
View File
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<!-- Repository avatar: the app's wallet icon, full-bleed, with a sync badge
marking it as the server. No text or thin lines, so it survives 24-40 px. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop stop-color="#7777ef"/>
<stop offset="1" stop-color="#4242b8"/>
</linearGradient>
<linearGradient id="shine" x1="80" y1="140" x2="380" y2="420" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff"/>
<stop offset="1" stop-color="#dedfff"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<!-- Wallet, scaled up from web/icon.svg -->
<g transform="translate(-34 -46) scale(1.12)">
<path d="M141 173c0-25 20-45 45-45h140c25 0 45 20 45 45v18H186c-25 0-45 20-45 45v-63Z" fill="#fff" opacity=".5"/>
<path d="M116 222c0-28 23-51 51-51h179c28 0 51 23 51 51v126c0 28-23 51-51 51H167c-28 0-51-23-51-51V222Z" fill="url(#shine)"/>
<path d="M302 258h95v86h-95c-24 0-43-19-43-43s19-43 43-43Z" fill="#5b5bd6"/>
<circle cx="306" cy="301" r="14" fill="#fff"/>
<path d="M184 250h44M184 289h44M184 328h44" stroke="#5b5bd6" stroke-width="20" stroke-linecap="round" opacity=".72"/>
</g>
<!-- Sync badge -->
<circle cx="392" cy="392" r="92" fill="#16a36a" stroke="#4a4ac4" stroke-width="16"/>
<g fill="none" stroke="#fff" stroke-width="20" stroke-linecap="round" stroke-linejoin="round">
<path d="M346 380a48 48 0 0 1 82-26"/>
<path d="M438 404a48 48 0 0 1-82 26"/>
</g>
<path d="M440 324v42h-42z" fill="#fff" stroke="#fff" stroke-width="6" stroke-linejoin="round"/>
<path d="M344 460v-42h42z" fill="#fff" stroke="#fff" stroke-width="6" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

+82
View File
@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<radialGradient id="glow" cx="512" cy="400" r="520" gradientUnits="userSpaceOnUse">
<stop stop-color="#5b5bd6" stop-opacity=".42"/>
<stop offset=".55" stop-color="#5b5bd6" stop-opacity=".08"/>
<stop offset="1" stop-color="#5b5bd6" stop-opacity="0"/>
</radialGradient>
<linearGradient id="bg" x1="0" y1="0" x2="1024" y2="1024" gradientUnits="userSpaceOnUse">
<stop stop-color="#15151c"/>
<stop offset="1" stop-color="#0d0d12"/>
</linearGradient>
<linearGradient id="iconbg" x1="70" y1="36" x2="446" y2="476" gradientUnits="userSpaceOnUse">
<stop stop-color="#7777ef"/>
<stop offset="1" stop-color="#4242b8"/>
</linearGradient>
<linearGradient id="shine" x1="144" y1="122" x2="366" y2="386" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff"/>
<stop offset="1" stop-color="#dedfff"/>
</linearGradient>
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1.4" fill="#ffffff" opacity=".06"/>
</pattern>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="#000" flood-opacity=".55"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<rect width="1024" height="1024" fill="url(#dots)"/>
<rect width="1024" height="1024" fill="url(#glow)"/>
<!-- Sync orbits around the app icon, with client devices on them -->
<g fill="none" stroke="#8b8bff" stroke-linecap="round">
<circle cx="512" cy="400" r="215" stroke-opacity=".22" stroke-width="2" stroke-dasharray="2 12"/>
<circle cx="512" cy="400" r="290" stroke-opacity=".12" stroke-width="2"/>
<path d="M512 110a290 290 0 0 1 251 145" stroke-opacity=".7" stroke-width="3"/>
<path d="M512 690a290 290 0 0 1-251-145" stroke-opacity=".7" stroke-width="3"/>
</g>
<path d="M763 255l-2-21 19 9z" fill="#8b8bff" opacity=".8"/>
<path d="M261 545l2 21-19-9z" fill="#8b8bff" opacity=".8"/>
<g font-family="JetBrains Mono" font-size="17" font-weight="600" fill="#c9c9ff" text-anchor="middle">
<g transform="translate(512 185)"><rect x="-48" y="-19" width="96" height="38" rx="19" fill="#1f1f2b" stroke="#3b3b55"/><text y="6">iOS</text></g>
<g transform="translate(727 400)"><rect x="-56" y="-19" width="112" height="38" rx="19" fill="#1f1f2b" stroke="#3b3b55"/><text y="6">macOS</text></g>
<g transform="translate(512 615)"><rect x="-64" y="-19" width="128" height="38" rx="19" fill="#1f1f2b" stroke="#3b3b55"/><text y="6">Android</text></g>
<g transform="translate(297 400)"><rect x="-48" y="-19" width="96" height="38" rx="19" fill="#1f1f2b" stroke="#3b3b55"/><text y="6">Web</text></g>
</g>
<!-- App icon (web/icon.svg) -->
<g transform="translate(384 272) scale(.5)" filter="url(#shadow)">
<rect x="28" y="28" width="456" height="456" rx="128" fill="url(#iconbg)"/>
<path d="M141 173c0-25 20-45 45-45h140c25 0 45 20 45 45v18H186c-25 0-45 20-45 45v-63Z" fill="#fff" opacity=".5"/>
<path d="M116 222c0-28 23-51 51-51h179c28 0 51 23 51 51v126c0 28-23 51-51 51H167c-28 0-51-23-51-51V222Z" fill="url(#shine)"/>
<path d="M302 258h95v86h-95c-24 0-43-19-43-43s19-43 43-43Z" fill="#5b5bd6"/>
<circle cx="306" cy="301" r="13" fill="#fff"/>
<path d="M184 250h44M184 289h44M184 328h44" stroke="#5b5bd6" stroke-width="18" stroke-linecap="round" opacity=".72"/>
</g>
<!-- Title block -->
<g font-family="Adwaita Sans" text-anchor="middle">
<text x="512" y="808" font-size="92" font-weight="800" letter-spacing="-2.5" fill="#f5f5f7">Balance <tspan fill="#9d9dff">Server</tspan></text>
<text x="512" y="862" font-size="29" font-weight="500" fill="#a4a5b2">Self-hosted sync &amp; accounts for Balance</text>
</g>
<g font-family="JetBrains Mono" font-size="18" font-weight="600" text-anchor="middle">
<g transform="translate(275 904)">
<rect width="100" height="42" rx="11" fill="#2a1f1a" stroke="#5a3a28"/>
<text x="50" y="27" fill="#f2a46b">Rust</text>
</g>
<g transform="translate(389 904)">
<rect width="116" height="42" rx="11" fill="#17222b" stroke="#28465a"/>
<text x="58" y="27" fill="#78bde8">SQLite</text>
</g>
<g transform="translate(519 904)">
<rect width="116" height="42" rx="11" fill="#162329" stroke="#244b5a"/>
<text x="58" y="27" fill="#6fd0e8">Docker</text>
</g>
<g transform="translate(649 904)">
<rect width="100" height="42" rx="11" fill="#14251d" stroke="#235a40"/>
<text x="50" y="27" fill="#5fd39b">PWA</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+87
View File
@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640">
<defs>
<radialGradient id="glow" cx="300" cy="320" r="460" gradientUnits="userSpaceOnUse">
<stop stop-color="#5b5bd6" stop-opacity=".42"/>
<stop offset=".55" stop-color="#5b5bd6" stop-opacity=".08"/>
<stop offset="1" stop-color="#5b5bd6" stop-opacity="0"/>
</radialGradient>
<linearGradient id="bg" x1="0" y1="0" x2="1280" y2="640" gradientUnits="userSpaceOnUse">
<stop stop-color="#15151c"/>
<stop offset="1" stop-color="#0d0d12"/>
</linearGradient>
<linearGradient id="iconbg" x1="70" y1="36" x2="446" y2="476" gradientUnits="userSpaceOnUse">
<stop stop-color="#7777ef"/>
<stop offset="1" stop-color="#4242b8"/>
</linearGradient>
<linearGradient id="shine" x1="144" y1="122" x2="366" y2="386" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff"/>
<stop offset="1" stop-color="#dedfff"/>
</linearGradient>
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1.4" fill="#ffffff" opacity=".06"/>
</pattern>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="#000" flood-opacity=".55"/>
</filter>
</defs>
<rect width="1280" height="640" fill="url(#bg)"/>
<rect width="1280" height="640" fill="url(#dots)"/>
<rect width="1280" height="640" fill="url(#glow)"/>
<!-- Sync orbits around the app icon, with client devices on them -->
<g fill="none" stroke="#8b8bff" stroke-linecap="round">
<circle cx="300" cy="320" r="215" stroke-opacity=".22" stroke-width="2" stroke-dasharray="2 12"/>
<circle cx="300" cy="320" r="268" stroke-opacity=".12" stroke-width="2"/>
<path d="M300 52a268 268 0 0 1 232 134" stroke-opacity=".7" stroke-width="3"/>
<path d="M300 588a268 268 0 0 1-232-134" stroke-opacity=".7" stroke-width="3"/>
</g>
<path d="M532 186l-2-20 18 9z" fill="#8b8bff" opacity=".8"/>
<path d="M68 454l2 20-18-9z" fill="#8b8bff" opacity=".8"/>
<g font-family="JetBrains Mono" font-size="15" font-weight="600" fill="#c9c9ff" text-anchor="middle">
<g transform="translate(300 105)"><rect x="-44" y="-17" width="88" height="34" rx="17" fill="#1f1f2b" stroke="#3b3b55"/><text y="5">iOS</text></g>
<g transform="translate(486 320)"><rect x="-50" y="-17" width="100" height="34" rx="17" fill="#1f1f2b" stroke="#3b3b55"/><text y="5">macOS</text></g>
<g transform="translate(300 535)"><rect x="-58" y="-17" width="116" height="34" rx="17" fill="#1f1f2b" stroke="#3b3b55"/><text y="5">Android</text></g>
<g transform="translate(114 320)"><rect x="-44" y="-17" width="88" height="34" rx="17" fill="#1f1f2b" stroke="#3b3b55"/><text y="5">Web</text></g>
</g>
<!-- App icon (web/icon.svg) -->
<g transform="translate(180 200) scale(.46875)" filter="url(#shadow)">
<rect x="28" y="28" width="456" height="456" rx="128" fill="url(#iconbg)"/>
<path d="M141 173c0-25 20-45 45-45h140c25 0 45 20 45 45v18H186c-25 0-45 20-45 45v-63Z" fill="#fff" opacity=".5"/>
<path d="M116 222c0-28 23-51 51-51h179c28 0 51 23 51 51v126c0 28-23 51-51 51H167c-28 0-51-23-51-51V222Z" fill="url(#shine)"/>
<path d="M302 258h95v86h-95c-24 0-43-19-43-43s19-43 43-43Z" fill="#5b5bd6"/>
<circle cx="306" cy="301" r="13" fill="#fff"/>
<path d="M184 250h44M184 289h44M184 328h44" stroke="#5b5bd6" stroke-width="18" stroke-linecap="round" opacity=".72"/>
</g>
<!-- Title block -->
<g font-family="Adwaita Sans" fill="#f5f5f7">
<text x="640" y="250" font-size="88" font-weight="800" letter-spacing="-2.5">Balance</text>
<text x="640" y="342" font-size="88" font-weight="800" letter-spacing="-2.5" fill="#9d9dff">Server</text>
<text x="642" y="398" font-size="27" font-weight="500" fill="#a4a5b2">Self-hosted sync &amp; accounts for Balance</text>
</g>
<g font-family="JetBrains Mono" font-size="17" font-weight="600">
<g transform="translate(642 446)">
<rect width="96" height="40" rx="10" fill="#2a1f1a" stroke="#5a3a28"/>
<text x="48" y="26" text-anchor="middle" fill="#f2a46b">Rust</text>
</g>
<g transform="translate(752 446)">
<rect width="112" height="40" rx="10" fill="#17222b" stroke="#28465a"/>
<text x="56" y="26" text-anchor="middle" fill="#78bde8">SQLite</text>
</g>
<g transform="translate(878 446)">
<rect width="112" height="40" rx="10" fill="#162329" stroke="#244b5a"/>
<text x="56" y="26" text-anchor="middle" fill="#6fd0e8">Docker</text>
</g>
<g transform="translate(1004 446)">
<rect width="88" height="40" rx="10" fill="#14251d" stroke="#235a40"/>
<text x="44" y="26" text-anchor="middle" fill="#5fd39b">PWA</text>
</g>
</g>
<text x="642" y="540" font-family="JetBrains Mono" font-size="18" fill="#6c6d7c">
<tspan fill="#9d9dff">POST</tspan> /v1/sync <tspan fill="#4a4b58">→</tspan> <tspan fill="#5fd39b">200 OK</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

-21
View File
@@ -1,21 +0,0 @@
module balance-server
go 1.25.0
require (
golang.org/x/crypto v0.41.0
modernc.org/sqlite v1.39.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
-51
View File
@@ -1,51 +0,0 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-61
View File
@@ -1,61 +0,0 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argonMemory = 64 * 1024
argonTime = 3
argonThreads = 2
argonKeyLen = 32
)
func HashPassword(password string) (string, error) {
if len(password) < 8 {
return "", errors.New("password must contain at least 8 characters")
}
if len(password) > 1024 {
return "", errors.New("password is too long")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
}
func VerifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
return false
}
var memory uint32
var iterations uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
return false
}
if memory > 256*1024 || iterations > 10 || threads > 16 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(expected) == 0 {
return false
}
actual := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
-90
View File
@@ -1,90 +0,0 @@
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
)
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Issuer string `json:"iss"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
ID string `json:"jti"`
}
type TokenManager struct {
secret []byte
accessTTL time.Duration
}
func NewTokenManager(secret string, accessTTL time.Duration) TokenManager {
return TokenManager{secret: []byte(secret), accessTTL: accessTTL}
}
func (manager TokenManager) AccessToken(userID, email string) (string, time.Time, error) {
now := time.Now().UTC()
expires := now.Add(manager.accessTTL)
id, err := RandomToken(16)
if err != nil {
return "", time.Time{}, err
}
header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
claims, _ := json.Marshal(Claims{Subject: userID, Email: email, Issuer: "balance-server", Issued: now.Unix(), Expires: expires.Unix(), ID: id})
unsigned := encode(header) + "." + encode(claims)
signature := manager.sign(unsigned)
return unsigned + "." + signature, expires, nil
}
func (manager TokenManager) Verify(token string) (Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return Claims{}, errors.New("invalid token")
}
unsigned := parts[0] + "." + parts[1]
expected := manager.sign(unsigned)
if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
return Claims{}, errors.New("invalid token signature")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Claims{}, errors.New("invalid token payload")
}
var claims Claims
if err := json.Unmarshal(payload, &claims); err != nil {
return Claims{}, errors.New("invalid token claims")
}
if claims.Issuer != "balance-server" || claims.Subject == "" || time.Now().UTC().Unix() >= claims.Expires {
return Claims{}, errors.New("expired or invalid token")
}
return claims, nil
}
func RandomToken(bytes int) (string, error) {
value := make([]byte, bytes)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func TokenHash(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
func (manager TokenManager) sign(value string) string {
mac := hmac.New(sha256.New, manager.secret)
_, _ = mac.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func encode(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
-93
View File
@@ -1,93 +0,0 @@
package config
import (
"errors"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Address string
DatabasePath string
JWTSecret string
AccessTTL time.Duration
RefreshTTL time.Duration
AllowRegistration bool
CORSOrigins []string
}
func Load() (Config, error) {
accessTTL, err := duration("BALANCE_ACCESS_TTL", 15*time.Minute)
if err != nil {
return Config{}, err
}
refreshTTL, err := duration("BALANCE_REFRESH_TTL", 30*24*time.Hour)
if err != nil {
return Config{}, err
}
allowRegistration, err := boolean("BALANCE_ALLOW_REGISTRATION", true)
if err != nil {
return Config{}, err
}
cfg := Config{
Address: env("BALANCE_ADDR", ":8080"),
DatabasePath: env("BALANCE_DB_PATH", "./data/balance.db"),
JWTSecret: strings.TrimSpace(os.Getenv("BALANCE_JWT_SECRET")),
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
AllowRegistration: allowRegistration,
CORSOrigins: split(os.Getenv("BALANCE_CORS_ORIGINS")),
}
if len(cfg.JWTSecret) < 32 {
return Config{}, errors.New("BALANCE_JWT_SECRET must contain at least 32 characters")
}
return cfg, nil
}
func env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func duration(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, errors.New(key + " must be a Go duration such as 15m or 720h")
}
return parsed, nil
}
func boolean(key string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, errors.New(key + " must be true or false")
}
return parsed, nil
}
func split(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if item := strings.TrimSpace(part); item != "" {
result = append(result, item)
}
}
return result
}
-245
View File
@@ -1,245 +0,0 @@
package httpapi
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"balance-server/internal/auth"
)
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
type refreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
type userResponse struct {
ID string `json:"id"`
Email string `json:"email"`
}
type sessionResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt time.Time `json:"expiresAt"`
User userResponse `json:"user"`
}
func (api *API) register(w http.ResponseWriter, r *http.Request) {
if !api.cfg.AllowRegistration {
writeError(w, http.StatusForbidden, "registration_disabled", "Registration is disabled")
return
}
var request credentials
if !decodeJSON(w, r, &request) {
return
}
email, err := normalizeEmail(request.Email)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_email", "Enter a valid email address")
return
}
passwordHash, err := auth.HashPassword(request.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_password", err.Error())
return
}
userID, err := identifier()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account")
return
}
_, err = api.db.ExecContext(r.Context(), `INSERT INTO users(id,email,password_hash,created_at) VALUES(?,?,?,?)`, userID, email, passwordHash, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
if containsUnique(err) {
writeError(w, http.StatusConflict, "email_exists", "An account with this email already exists")
return
}
api.logger.Error("create user", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account")
return
}
response, err := api.createSession(r, userID, email)
if err != nil {
api.logger.Error("create session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session")
return
}
api.setSessionCookies(w, r, response)
writeJSON(w, http.StatusCreated, response)
}
func (api *API) login(w http.ResponseWriter, r *http.Request) {
var request credentials
if !decodeJSON(w, r, &request) {
return
}
email, err := normalizeEmail(request.Email)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect")
return
}
var userID, passwordHash string
err = api.db.QueryRowContext(r.Context(), `SELECT id,password_hash FROM users WHERE email=?`, email).Scan(&userID, &passwordHash)
if err != nil || !auth.VerifyPassword(passwordHash, request.Password) {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect")
return
}
response, err := api.createSession(r, userID, email)
if err != nil {
api.logger.Error("create session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session")
return
}
api.setSessionCookies(w, r, response)
writeJSON(w, http.StatusOK, response)
}
func (api *API) refresh(w http.ResponseWriter, r *http.Request) {
var request refreshRequest
if !decodeJSON(w, r, &request) {
return
}
request.RefreshToken = api.refreshToken(r, request.RefreshToken)
if request.RefreshToken == "" {
writeError(w, http.StatusBadRequest, "refresh_token_required", "Refresh token is required")
return
}
tx, err := api.db.BeginTx(r.Context(), nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session")
return
}
defer tx.Rollback()
var userID, email, expiresText string
err = tx.QueryRowContext(r.Context(), `SELECT u.id,u.email,t.expires_at FROM refresh_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=? AND t.revoked_at IS NULL`, auth.TokenHash(request.RefreshToken)).Scan(&userID, &email, &expiresText)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired")
return
}
expires, err := time.Parse(time.RFC3339Nano, expiresText)
if err != nil || !time.Now().UTC().Before(expires) {
writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired")
return
}
_, err = tx.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=?`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session")
return
}
response, err := api.createSessionWithExecutor(r, userID, email, tx)
if err != nil || tx.Commit() != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session")
return
}
api.setSessionCookies(w, r, response)
writeJSON(w, http.StatusOK, response)
}
func (api *API) logout(w http.ResponseWriter, r *http.Request) {
var request refreshRequest
if !decodeJSON(w, r, &request) {
return
}
request.RefreshToken = api.refreshToken(r, request.RefreshToken)
if request.RefreshToken != "" {
_, _ = api.db.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken))
}
api.clearSessionCookies(w, r)
w.WriteHeader(http.StatusNoContent)
}
func (api *API) me(w http.ResponseWriter, r *http.Request) {
value := claims(r)
writeJSON(w, http.StatusOK, userResponse{ID: value.Subject, Email: value.Email})
}
func (api *API) createSession(r *http.Request, userID, email string) (sessionResponse, error) {
return api.createSessionWithExecutor(r, userID, email, api.db)
}
func (api *API) createSessionWithExecutor(r *http.Request, userID, email string, executor interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}) (sessionResponse, error) {
accessToken, expiresAt, err := api.tokens.AccessToken(userID, email)
if err != nil {
return sessionResponse{}, err
}
refreshToken, err := auth.RandomToken(48)
if err != nil {
return sessionResponse{}, err
}
refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL)
_, err = executor.ExecContext(r.Context(), `INSERT INTO refresh_tokens(token_hash,user_id,expires_at,created_at) VALUES(?,?,?,?)`, auth.TokenHash(refreshToken), userID, refreshExpires.Format(time.RFC3339Nano), time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return sessionResponse{}, err
}
return sessionResponse{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, User: userResponse{ID: userID, Email: email}}, nil
}
func identifier() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}
func containsUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
const (
accessCookieName = "balance_access"
refreshCookieName = "balance_refresh"
)
func (api *API) setSessionCookies(w http.ResponseWriter, r *http.Request, session sessionResponse) {
secure := requestIsSecure(r)
http.SetCookie(w, &http.Cookie{
Name: accessCookieName, Value: session.AccessToken, Path: "/", HttpOnly: true,
Secure: secure, SameSite: http.SameSiteStrictMode,
Expires: session.ExpiresAt, MaxAge: maxAge(session.ExpiresAt),
})
refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL)
http.SetCookie(w, &http.Cookie{
Name: refreshCookieName, Value: session.RefreshToken, Path: "/", HttpOnly: true,
Secure: secure, SameSite: http.SameSiteStrictMode,
Expires: refreshExpires, MaxAge: maxAge(refreshExpires),
})
}
func (api *API) clearSessionCookies(w http.ResponseWriter, r *http.Request) {
for _, name := range []string{accessCookieName, refreshCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", HttpOnly: true, Secure: requestIsSecure(r),
SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(1, 0),
})
}
}
func (api *API) refreshToken(r *http.Request, value string) string {
if value = strings.TrimSpace(value); value != "" {
return value
}
if cookie, err := r.Cookie(refreshCookieName); err == nil {
return strings.TrimSpace(cookie.Value)
}
return ""
}
func requestIsSecure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func maxAge(expires time.Time) int {
seconds := int(time.Until(expires).Seconds())
if seconds < 1 {
return 1
}
return seconds
}
-185
View File
@@ -1,185 +0,0 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"balance-server/internal/auth"
"balance-server/internal/config"
)
type contextKey string
const claimsKey contextKey = "claims"
type API struct {
cfg config.Config
db *sql.DB
tokens auth.TokenManager
logger *slog.Logger
limiter *rateLimiter
}
func New(cfg config.Config, db *sql.DB, logger *slog.Logger) http.Handler {
api := &API{cfg: cfg, db: db, tokens: auth.NewTokenManager(cfg.JWTSecret, cfg.AccessTTL), logger: logger, limiter: newRateLimiter()}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", api.health)
mux.Handle("POST /v1/auth/register", api.limited(http.HandlerFunc(api.register)))
mux.Handle("POST /v1/auth/login", api.limited(http.HandlerFunc(api.login)))
mux.Handle("POST /v1/auth/refresh", api.limited(http.HandlerFunc(api.refresh)))
mux.HandleFunc("POST /v1/auth/logout", api.logout)
mux.Handle("GET /v1/me", api.authorized(http.HandlerFunc(api.me)))
mux.Handle("POST /v1/sync", api.authorized(http.HandlerFunc(api.sync)))
mux.HandleFunc("GET /", api.webApp)
return api.recover(api.security(api.cors(mux)))
}
func (api *API) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": time.Now().UTC()})
}
func (api *API) authorized(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := strings.TrimSpace(r.Header.Get("Authorization"))
token := ""
if strings.HasPrefix(header, "Bearer ") {
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
} else if cookie, err := r.Cookie(accessCookieName); err == nil {
token = strings.TrimSpace(cookie.Value)
}
if token == "" {
writeError(w, http.StatusUnauthorized, "authorization_required", "Authorization Bearer token is required")
return
}
claims, err := api.tokens.Verify(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_token", "Access token is invalid or expired")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
})
}
func claims(r *http.Request) auth.Claims {
value, _ := r.Context().Value(claimsKey).(auth.Claims)
return value
}
func (api *API) security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; manifest-src 'self'; script-src 'self'; style-src 'self'; worker-src 'self'")
if strings.HasPrefix(r.URL.Path, "/v1/") {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
func (api *API) cors(next http.Handler) http.Handler {
allowed := make(map[string]bool, len(api.cfg.CORSOrigins))
for _, origin := range api.cfg.CORSOrigins {
allowed[origin] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (allowed[origin] || allowed["*"]) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (api *API) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if value := recover(); value != nil {
api.logger.Error("request panic", "value", value)
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "Request body is not valid JSON")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}})
}
type rateLimiter struct {
mu sync.Mutex
clients map[string]*rateBucket
}
type rateBucket struct {
started time.Time
count int
}
func newRateLimiter() *rateLimiter { return &rateLimiter{clients: make(map[string]*rateBucket)} }
func (api *API) limited(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
now := time.Now()
api.limiter.mu.Lock()
bucket := api.limiter.clients[host]
if bucket == nil || now.Sub(bucket.started) >= time.Minute {
bucket = &rateBucket{started: now}
api.limiter.clients[host] = bucket
}
bucket.count++
blocked := bucket.count > 30
api.limiter.mu.Unlock()
if blocked {
w.Header().Set("Retry-After", "60")
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many authentication requests")
return
}
next.ServeHTTP(w, r)
})
}
func normalizeEmail(value string) (string, error) {
email := strings.ToLower(strings.TrimSpace(value))
if len(email) < 3 || len(email) > 254 || !strings.Contains(email, "@") {
return "", errors.New("invalid email")
}
return email, nil
}
-204
View File
@@ -1,204 +0,0 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"balance-server/internal/config"
"balance-server/internal/database"
)
func TestRegisterAndSynchronize(t *testing.T) {
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
cfg := config.Config{JWTSecret: "test-secret-that-is-longer-than-thirty-two-characters", AccessTTL: 15 * time.Minute, RefreshTTL: time.Hour, AllowRegistration: true}
server := httptest.NewServer(New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
register := request(t, http.MethodPost, server.URL+"/v1/auth/register", map[string]any{"email": "person@example.com", "password": "correct horse battery staple"}, "")
if register.StatusCode != http.StatusCreated {
t.Fatalf("register status: %d", register.StatusCode)
}
var session sessionResponse
decode(t, register, &session)
if session.AccessToken == "" || session.RefreshToken == "" {
t.Fatal("session tokens are empty")
}
now := time.Now().UTC().Add(-time.Second)
pull := false
syncBody := syncRequest{DeviceID: "test", Pull: &pull, Changes: []syncChange{{Entity: "category", ID: "category-1", UpdatedAt: now, Payload: json.RawMessage(`{"name":"Food"}`)}}}
synchronized := request(t, http.MethodPost, server.URL+"/v1/sync", syncBody, session.AccessToken)
if synchronized.StatusCode != http.StatusOK {
t.Fatalf("sync status: %d", synchronized.StatusCode)
}
var result syncResponse
decode(t, synchronized, &result)
if result.Cursor != 0 || len(result.Changes) != 0 {
t.Fatalf("push-only response included pulled changes: %+v", result)
}
pull = true
synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{DeviceID: "test", Pull: &pull}, session.AccessToken)
if synchronized.StatusCode != http.StatusOK {
t.Fatalf("pull status: %d", synchronized.StatusCode)
}
decode(t, synchronized, &result)
if result.Cursor == 0 || len(result.Changes) != 1 || result.Changes[0].ID != "category-1" {
t.Fatalf("unexpected sync response: %+v", result)
}
pageStart := result.Cursor
pageChanges := make([]syncChange, syncPageSize+1)
for index := range pageChanges {
pageChanges[index] = syncChange{
Entity: "category", ID: "page-" + time.Unix(int64(index), 0).UTC().Format("150405"),
UpdatedAt: now.Add(time.Duration(index+1) * time.Millisecond), Payload: json.RawMessage(`{"name":"Paged"}`),
}
}
pull = false
synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{DeviceID: "test", Pull: &pull, Changes: pageChanges}, session.AccessToken)
if synchronized.StatusCode != http.StatusOK {
t.Fatalf("paged push status: %d", synchronized.StatusCode)
}
synchronized.Body.Close()
pull = true
synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{Cursor: pageStart, DeviceID: "test", Pull: &pull}, session.AccessToken)
decode(t, synchronized, &result)
if len(result.Changes) != syncPageSize || !result.HasMore {
t.Fatalf("first page mismatch: count=%d hasMore=%v", len(result.Changes), result.HasMore)
}
synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{Cursor: result.Cursor, DeviceID: "test", Pull: &pull}, session.AccessToken)
decode(t, synchronized, &result)
if len(result.Changes) != 1 || result.HasMore {
t.Fatalf("last page mismatch: count=%d hasMore=%v", len(result.Changes), result.HasMore)
}
}
func TestEmbeddedWebAppAndCookieSession(t *testing.T) {
db, err := database.Open(filepath.Join(t.TempDir(), "web.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
cfg := config.Config{JWTSecret: "test-secret-that-is-longer-than-thirty-two-characters", AccessTTL: 15 * time.Minute, RefreshTTL: time.Hour, AllowRegistration: true}
server := httptest.NewServer(New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
home, err := http.Get(server.URL + "/")
if err != nil {
t.Fatal(err)
}
homeBody, err := io.ReadAll(home.Body)
home.Body.Close()
if err != nil {
t.Fatal(err)
}
if home.StatusCode != http.StatusOK || !strings.Contains(string(homeBody), "id=\"app-view\"") {
t.Fatalf("web app response: status=%d body=%q", home.StatusCode, string(homeBody))
}
if !strings.Contains(home.Header.Get("Content-Security-Policy"), "script-src 'self'") {
t.Fatal("web app is missing its content security policy")
}
register := request(t, http.MethodPost, server.URL+"/v1/auth/register", map[string]any{"email": "web@example.com", "password": "correct horse battery staple"}, "")
if register.StatusCode != http.StatusCreated {
t.Fatalf("register status: %d", register.StatusCode)
}
cookies := register.Cookies()
var session sessionResponse
decode(t, register, &session)
var accessCookie, refreshCookie *http.Cookie
for _, cookie := range cookies {
switch cookie.Name {
case accessCookieName:
accessCookie = cookie
case refreshCookieName:
refreshCookie = cookie
}
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("insecure session cookie: %+v", cookie)
}
}
if accessCookie == nil || refreshCookie == nil {
t.Fatal("session cookies were not returned")
}
meRequest, err := http.NewRequest(http.MethodGet, server.URL+"/v1/me", nil)
if err != nil {
t.Fatal(err)
}
meRequest.AddCookie(accessCookie)
me, err := http.DefaultClient.Do(meRequest)
if err != nil {
t.Fatal(err)
}
if me.StatusCode != http.StatusOK {
t.Fatalf("cookie authentication status: %d", me.StatusCode)
}
var user userResponse
decode(t, me, &user)
if user.Email != "web@example.com" {
t.Fatalf("unexpected web user: %+v", user)
}
refreshData, err := json.Marshal(refreshRequest{})
if err != nil {
t.Fatal(err)
}
refreshHTTP, err := http.NewRequest(http.MethodPost, server.URL+"/v1/auth/refresh", bytes.NewReader(refreshData))
if err != nil {
t.Fatal(err)
}
refreshHTTP.Header.Set("Content-Type", "application/json")
refreshHTTP.AddCookie(refreshCookie)
refreshed, err := http.DefaultClient.Do(refreshHTTP)
if err != nil {
t.Fatal(err)
}
if refreshed.StatusCode != http.StatusOK {
t.Fatalf("cookie refresh status: %d", refreshed.StatusCode)
}
refreshed.Body.Close()
}
func request(t *testing.T, method, url string, value any, token string) *http.Response {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(method, url, bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return response
}
func decode(t *testing.T, response *http.Response, value any) {
t.Helper()
defer response.Body.Close()
if err := json.NewDecoder(response.Body).Decode(value); err != nil {
t.Fatal(err)
}
}
-169
View File
@@ -1,169 +0,0 @@
package httpapi
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"time"
)
const maxSyncChanges = 5000
const syncPageSize = 500
type syncChange struct {
Entity string `json:"entity"`
ID string `json:"id"`
Deleted bool `json:"deleted"`
UpdatedAt time.Time `json:"updatedAt"`
Payload json.RawMessage `json:"payload,omitempty"`
Version int64 `json:"version,omitempty"`
Sequence int64 `json:"sequence,omitempty"`
}
type syncRequest struct {
Cursor int64 `json:"cursor"`
DeviceID string `json:"deviceId"`
Changes []syncChange `json:"changes"`
Pull *bool `json:"pull,omitempty"`
}
type syncResponse struct {
Cursor int64 `json:"cursor"`
Changes []syncChange `json:"changes"`
ServerTime time.Time `json:"serverTime"`
HasMore bool `json:"hasMore"`
}
func (api *API) sync(w http.ResponseWriter, r *http.Request) {
var request syncRequest
if !decodeJSON(w, r, &request) {
return
}
if request.Cursor < 0 || len(request.Changes) > maxSyncChanges {
writeError(w, http.StatusBadRequest, "invalid_sync_request", "Cursor or change count is invalid")
return
}
for index := range request.Changes {
if err := validateChange(request.Changes[index]); err != nil {
writeError(w, http.StatusBadRequest, "invalid_change", err.Error())
return
}
}
userID := claims(r).Subject
tx, err := api.db.BeginTx(r.Context(), nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not begin synchronization")
return
}
defer tx.Rollback()
for _, change := range request.Changes {
if err := applyChange(r, tx, userID, change); err != nil {
api.logger.Error("apply sync change", "error", err)
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not apply changes")
return
}
}
changes := make([]syncChange, 0)
cursor := request.Cursor
hasMore := false
shouldPull := request.Pull == nil || *request.Pull
if shouldPull {
changes, cursor, hasMore, err = loadChanges(r, tx, userID, request.Cursor)
if err != nil {
api.logger.Error("load sync changes", "error", err)
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not load changes")
return
}
}
if err := tx.Commit(); err != nil {
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not complete synchronization")
return
}
writeJSON(w, http.StatusOK, syncResponse{Cursor: cursor, Changes: changes, ServerTime: time.Now().UTC(), HasMore: hasMore})
}
func validateChange(change syncChange) error {
if change.Entity != "transaction" && change.Entity != "category" && change.Entity != "budget" {
return errors.New("entity must be transaction, category, or budget")
}
if len(change.ID) < 1 || len(change.ID) > 128 {
return errors.New("record id is invalid")
}
if change.UpdatedAt.IsZero() {
return errors.New("updatedAt is required")
}
if change.UpdatedAt.After(time.Now().UTC().Add(24 * time.Hour)) {
return errors.New("updatedAt is too far in the future")
}
if !change.Deleted && (len(change.Payload) == 0 || !json.Valid(change.Payload)) {
return errors.New("payload is required for active records")
}
if len(change.Payload) > 256*1024 {
return errors.New("payload is too large")
}
return nil
}
func applyChange(r *http.Request, tx *sql.Tx, userID string, incoming syncChange) error {
var currentUpdated string
var currentVersion int64
err := tx.QueryRowContext(r.Context(), `SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?`, userID, incoming.Entity, incoming.ID).Scan(&currentUpdated, &currentVersion)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
parsed, parseErr := time.Parse(time.RFC3339Nano, currentUpdated)
if parseErr != nil {
return parseErr
}
if !incoming.UpdatedAt.After(parsed) {
return nil
}
}
version := currentVersion + 1
updated := incoming.UpdatedAt.UTC().Format(time.RFC3339Nano)
var payload any
if !incoming.Deleted {
payload = string(incoming.Payload)
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO records(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)
ON CONFLICT(user_id,entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version`,
userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version)
if err != nil {
return err
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)`, userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version)
return err
}
func loadChanges(r *http.Request, tx *sql.Tx, userID string, after int64) ([]syncChange, int64, bool, error) {
rows, err := tx.QueryContext(r.Context(), `SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM changes WHERE user_id=? AND sequence>? ORDER BY sequence ASC LIMIT ?`, userID, after, syncPageSize+1)
if err != nil {
return nil, after, false, err
}
defer rows.Close()
result := make([]syncChange, 0)
cursor := after
hasMore := false
for rows.Next() {
if len(result) == syncPageSize {
hasMore = true
break
}
var item syncChange
var payload sql.NullString
var updated string
if err := rows.Scan(&item.Sequence, &item.Entity, &item.ID, &payload, &updated, &item.Deleted, &item.Version); err != nil {
return nil, after, false, err
}
item.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated)
if err != nil {
return nil, after, false, err
}
if payload.Valid {
item.Payload = json.RawMessage(payload.String)
}
cursor = item.Sequence
result = append(result, item)
}
return result, cursor, hasMore, rows.Err()
}
-34
View File
@@ -1,34 +0,0 @@
package httpapi
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed web/*
var embeddedWeb embed.FS
var webContent = func() http.Handler {
content, err := fs.Sub(embeddedWeb, "web")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(content))
}()
func (api *API) webApp(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v1/") {
writeError(w, http.StatusNotFound, "not_found", "Endpoint not found")
return
}
cleaned := path.Clean(r.URL.Path)
if cleaned == "." || cleaned == "/" || cleaned == "/index.html" || cleaned == "/service-worker.js" {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
webContent.ServeHTTP(w, r)
}
+326
View File
@@ -0,0 +1,326 @@
//! Attachment blobs (contract v2, section 8).
//!
//! Bytes live on disk under the attachments directory, one subdirectory per
//! data space; their metadata lives in `attachment_blobs`. A blob is garbage
//! when no live `attachment` record of its space references its SHA-256.
//! Blobs that were uploaded but not referenced yet are kept for a grace period,
//! because clients upload the bytes before syncing the record.
use crate::sync::Space;
use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
use rusqlite::{Connection, OptionalExtension, params};
use sha2::{Digest, Sha256};
use std::{
fs,
io::{self, Write},
path::{Path, PathBuf},
};
pub const MAX_BYTES: usize = 10 * 1024 * 1024;
pub const TYPES: [&str; 4] = ["image/jpeg", "image/png", "image/heic", "application/pdf"];
/// How long an uploaded, never referenced blob survives garbage collection.
pub const GRACE: TimeDelta = TimeDelta::hours(24);
pub struct Store {
dir: PathBuf,
}
impl Store {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
fn space_dir(&self, space: &Space) -> PathBuf {
let (kind, id) = match space {
Space::User(id) => ("users", id.as_str()),
Space::Ledger(id) => ("ledgers", id.as_str()),
};
// Server-generated ids are hex/UUIDs; anything else is hashed so a
// database value can never become a path component such as "..".
let safe = !id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
let name = if safe {
id.to_owned()
} else {
crate::auth::token_hash(id)
};
self.dir.join(kind).join(name)
}
fn path(&self, space: &Space, sha: &str) -> PathBuf {
self.space_dir(space).join(&sha[..2]).join(sha)
}
/// Write bytes to a temporary file of the space, ready to be renamed.
fn stage(&self, space: &Space, bytes: &[u8]) -> io::Result<PathBuf> {
let dir = self.space_dir(space);
create_dir(&dir)?;
let name = crate::auth::identifier().map_err(io::Error::other)?;
let path = dir.join(format!(".upload-{name}"));
let mut file = fs::File::create(&path)?;
let written = file.write_all(bytes).and_then(|_| file.sync_all());
if let Err(e) = written {
let _ = fs::remove_file(&path);
return Err(e);
}
Ok(path)
}
fn install(&self, staged: &Path, space: &Space, sha: &str) -> io::Result<()> {
let path = self.path(space, sha);
if let Some(parent) = path.parent() {
create_dir(parent)?;
}
fs::rename(staged, path)
}
pub fn read(&self, space: &Space, sha: &str) -> io::Result<Vec<u8>> {
fs::read(self.path(space, sha))
}
/// Remove blob files whose rows were already deleted.
pub fn remove(&self, space: &Space, shas: &[String]) {
for sha in shas {
if valid_sha(sha) {
match fs::remove_file(self.path(space, sha)) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => eprintln!("attachment cleanup failed: {}", e.kind()),
}
}
}
}
/// Remove every blob of a deleted space.
pub fn remove_space(&self, space: &Space) {
match fs::remove_dir_all(self.space_dir(space)) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => eprintln!("attachment directory cleanup failed: {}", e.kind()),
}
}
}
fn create_dir(path: &Path) -> io::Result<()> {
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o750);
}
builder.create(path)
}
/// SHA-256 in the canonical form of the contract: 64 lowercase hex digits.
pub fn valid_sha(sha: &str) -> bool {
sha.len() == 64 && sha.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// Accepted media type of a `Content-Type` header, ignoring parameters.
pub fn media_type(header: &str) -> Option<&'static str> {
let essence = header.split(';').next()?.trim().to_ascii_lowercase();
TYPES.into_iter().find(|t| *t == essence)
}
fn stamp(t: DateTime<Utc>) -> String {
// Fixed width, so stored stamps compare correctly as text.
t.to_rfc3339_opts(SecondsFormat::Nanos, true)
}
fn live_shas(space: &Space) -> String {
let (records, _, key) = space.tables();
format!(
"SELECT sha FROM (SELECT lower(CASE WHEN json_valid(payload) THEN json_extract(payload,'$.sha256') END) AS sha FROM {records} WHERE {key}=?2 AND entity='attachment' AND deleted=0) WHERE sha IS NOT NULL"
)
}
/// Delete rows of blobs no live attachment record references and return
/// their hashes. Blobs never referenced survive for [`GRACE`] after upload.
pub fn collect_garbage(
db: &Connection,
space: &Space,
now: DateTime<Utc>,
) -> rusqlite::Result<Vec<String>> {
let live = live_shas(space);
let key = space.key();
db.execute(
&format!(
"UPDATE attachment_blobs SET referenced=1 WHERE space=?1 AND referenced=0 AND sha256 IN ({live})"
),
params![key, space.id()],
)?;
let condition =
format!("space=?1 AND (referenced=1 OR uploaded_at<?3) AND sha256 NOT IN ({live})");
let garbage = db
.prepare(&format!(
"SELECT sha256 FROM attachment_blobs WHERE {condition}"
))?
.query_map(params![key, space.id(), stamp(now - GRACE)], |r| r.get(0))?
.collect::<rusqlite::Result<Vec<String>>>()?;
db.execute(
&format!("DELETE FROM attachment_blobs WHERE {condition}"),
params![key, space.id(), stamp(now - GRACE)],
)?;
Ok(garbage)
}
#[derive(Debug, PartialEq, Eq)]
pub enum PutError {
HashMismatch,
UnsupportedMediaType,
PayloadTooLarge,
Internal,
}
/// Store bytes in `space`. Returns `true` when the blob was created and
/// `false` for an idempotent repeat. The database lock is taken only to
/// install the already written file.
pub fn put(
db: &std::sync::Mutex<Connection>,
store: &Store,
space: &Space,
sha: &str,
content_type: &str,
bytes: &[u8],
) -> Result<bool, PutError> {
if !valid_sha(sha) {
return Err(PutError::HashMismatch);
}
let Some(media) = media_type(content_type) else {
return Err(PutError::UnsupportedMediaType);
};
if bytes.len() > MAX_BYTES {
return Err(PutError::PayloadTooLarge);
}
let digest: String = Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
if digest != sha {
return Err(PutError::HashMismatch);
}
let staged = store.stage(space, bytes).map_err(|_| PutError::Internal)?;
let result = install(db, store, space, sha, media, bytes.len(), &staged);
let _ = fs::remove_file(&staged);
result
}
fn install(
db: &std::sync::Mutex<Connection>,
store: &Store,
space: &Space,
sha: &str,
media: &str,
size: usize,
staged: &Path,
) -> Result<bool, PutError> {
let internal = |_| PutError::Internal;
let mut db = db.lock().unwrap_or_else(|e| e.into_inner());
let tx = db.transaction().map_err(internal)?;
let now = Utc::now();
let key = space.key();
let exists = tx
.query_row(
"SELECT 1 FROM attachment_blobs WHERE space=? AND sha256=?",
params![key, sha],
|_| Ok(()),
)
.optional()
.map_err(internal)?
.is_some();
let path = store.path(space, sha);
let file_missing = !path.is_file();
if exists {
// A repeated upload restarts the grace period: another device may
// be about to sync a new record that references these bytes.
tx.execute(
"UPDATE attachment_blobs SET uploaded_at=?, referenced=0 WHERE space=? AND sha256=?",
params![stamp(now), key, sha],
)
.map_err(internal)?;
} else {
tx.execute(
"INSERT INTO attachment_blobs(space,sha256,content_type,size,uploaded_at,referenced) VALUES(?,?,?,?,?,0)",
params![key, sha, media, size as i64, stamp(now)],
)
.map_err(internal)?;
}
let garbage = collect_garbage(&tx, space, now).map_err(internal)?;
if file_missing {
store
.install(staged, space, sha)
.map_err(|_| PutError::Internal)?;
}
if tx.commit().is_err() {
if file_missing {
let _ = fs::remove_file(&path);
}
return Err(PutError::Internal);
}
store.remove(space, &garbage);
Ok(!exists)
}
/// Bytes and media type of a blob stored in `space`.
pub fn get(
db: &std::sync::Mutex<Connection>,
store: &Store,
space: &Space,
sha: &str,
) -> Result<Option<(Vec<u8>, String)>, ()> {
if !valid_sha(sha) {
return Ok(None);
}
let db = db.lock().unwrap_or_else(|e| e.into_inner());
let media: Option<String> = db
.query_row(
"SELECT content_type FROM attachment_blobs WHERE space=? AND sha256=?",
params![space.key(), sha],
|r| r.get(0),
)
.optional()
.map_err(|_| ())?;
let Some(media) = media else {
return Ok(None);
};
// Reading under the lock keeps garbage collection from removing the
// file between the metadata check and the read.
match store.read(space, sha) {
Ok(bytes) => Ok(Some((bytes, media))),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(_) => Err(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hashes_and_media_types_are_strict() {
assert!(valid_sha(&"a".repeat(64)));
assert!(!valid_sha(&"A".repeat(64)));
assert!(!valid_sha(&"a".repeat(63)));
assert!(!valid_sha(&format!("{}g", "a".repeat(63))));
assert_eq!(media_type("IMAGE/JPEG; q=1"), Some("image/jpeg"));
assert_eq!(media_type("application/pdf"), Some("application/pdf"));
assert_eq!(media_type("image/gif"), None);
assert_eq!(media_type(""), None);
}
#[test]
fn unsafe_space_ids_never_become_path_components() {
let store = Store::new("/base");
let dir = store.space_dir(&Space::Ledger("../../etc".into()));
assert!(dir.starts_with("/base/ledgers"));
assert!(!dir.to_string_lossy().contains(".."));
assert_eq!(
store.space_dir(&Space::User("abc".into())),
Path::new("/base/users/abc")
);
}
}
+31 -2
View File
@@ -1,5 +1,8 @@
use chrono::TimeDelta; use chrono::TimeDelta;
use std::env; use std::{
env,
path::{Path, PathBuf},
};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Config { pub struct Config {
@@ -10,6 +13,7 @@ pub struct Config {
pub refresh_ttl: TimeDelta, pub refresh_ttl: TimeDelta,
pub allow_registration: bool, pub allow_registration: bool,
pub cors_origins: Vec<String>, pub cors_origins: Vec<String>,
pub attachments_dir: PathBuf,
} }
impl Config { impl Config {
@@ -25,9 +29,14 @@ impl Config {
if jwt_secret.len() < 32 { if jwt_secret.len() < 32 {
return Err("BALANCE_JWT_SECRET must contain at least 32 characters".into()); return Err("BALANCE_JWT_SECRET must contain at least 32 characters".into());
} }
let database_path = fallback("BALANCE_DB_PATH", "./data/balance.db");
let attachments_dir = match value("BALANCE_ATTACHMENTS_DIR") {
dir if dir.is_empty() => default_attachments_dir(&database_path),
dir => PathBuf::from(dir),
};
Ok(Self { Ok(Self {
address: fallback("BALANCE_ADDR", ":8080"), address: fallback("BALANCE_ADDR", ":8080"),
database_path: fallback("BALANCE_DB_PATH", "./data/balance.db"), database_path,
jwt_secret, jwt_secret,
access_ttl, access_ttl,
refresh_ttl, refresh_ttl,
@@ -38,10 +47,19 @@ impl Config {
.filter(|part| !part.is_empty()) .filter(|part| !part.is_empty())
.map(str::to_owned) .map(str::to_owned)
.collect(), .collect(),
attachments_dir,
}) })
} }
} }
/// `attachments` next to the database file.
pub fn default_attachments_dir(database_path: &str) -> PathBuf {
Path::new(database_path)
.parent()
.unwrap_or(Path::new(""))
.join("attachments")
}
fn value(key: &str) -> String { fn value(key: &str) -> String {
env::var(key).unwrap_or_default().trim().to_owned() env::var(key).unwrap_or_default().trim().to_owned()
} }
@@ -128,6 +146,17 @@ pub fn parse_go_duration(input: &str) -> Option<TimeDelta> {
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn attachments_default_next_to_database() {
assert_eq!(
default_attachments_dir("/data/balance.db"),
Path::new("/data/attachments")
);
assert_eq!(
default_attachments_dir("balance.db"),
Path::new("attachments")
);
}
#[test]
fn duration_matches_go_syntax_and_range() { fn duration_matches_go_syntax_and_range() {
assert_eq!(parse_go_duration("1h30m"), Some(TimeDelta::minutes(90))); assert_eq!(parse_go_duration("1h30m"), Some(TimeDelta::minutes(90)));
assert_eq!( assert_eq!(
+80 -12
View File
@@ -1,7 +1,8 @@
use rusqlite::Connection; use rusqlite::Connection;
use std::{error::Error, path::Path, time::Duration}; use std::{error::Error, path::Path, time::Duration};
/// Open the existing Go server's SQLite database without changing its schema. /// Open the SQLite database. Tables of the Go server's v1 schema are never
/// altered; contract v2 only adds new tables (ledgers, attachments).
pub fn open(path: &str) -> Result<Connection, Box<dyn Error + Send + Sync>> { pub fn open(path: &str) -> Result<Connection, Box<dyn Error + Send + Sync>> {
if let Some(parent) = Path::new(path) if let Some(parent) = Path::new(path)
.parent() .parent()
@@ -63,7 +64,61 @@ CREATE TABLE IF NOT EXISTS changes (
deleted INTEGER NOT NULL, deleted INTEGER NOT NULL,
version INTEGER NOT NULL version INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);", CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);
CREATE TABLE IF NOT EXISTS ledgers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ledger_members (
ledger_id TEXT NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner','member')),
joined_at TEXT NOT NULL,
PRIMARY KEY (ledger_id, user_id)
);
CREATE INDEX IF NOT EXISTS ledger_members_user_idx ON ledger_members(user_id);
CREATE TABLE IF NOT EXISTS ledger_invites (
code_hash TEXT PRIMARY KEY,
ledger_id TEXT NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
used_at TEXT,
used_by TEXT REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS ledger_invites_ledger_idx ON ledger_invites(ledger_id);
CREATE TABLE IF NOT EXISTS ledger_records (
ledger_id TEXT NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
entity TEXT NOT NULL,
record_id TEXT NOT NULL,
payload TEXT,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL,
PRIMARY KEY (ledger_id, entity, record_id)
);
CREATE TABLE IF NOT EXISTS ledger_changes (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
ledger_id TEXT NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
entity TEXT NOT NULL,
record_id TEXT NOT NULL,
payload TEXT,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL,
version INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS ledger_changes_ledger_sequence_idx ON ledger_changes(ledger_id, sequence);
CREATE TABLE IF NOT EXISTS attachment_blobs (
space TEXT NOT NULL,
sha256 TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at TEXT NOT NULL,
referenced INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (space, sha256)
);",
) )
} }
@@ -114,17 +169,10 @@ mod tests {
let path = directory.join("existing.sqlite"); let path = directory.join("existing.sqlite");
{ {
let db = Connection::open(&path).unwrap(); let db = Connection::open(&path).unwrap();
// Use the original schema, so a divergence between implementations // Use the original Go schema, so a divergence is caught even when
// is caught even when Rust's migration is internally consistent. // Rust's migration is internally consistent.
let go_source = include_str!("../internal/database/database.go"); db.execute_batch(include_str!("../tests/fixtures/go_schema_v1.sql"))
let schema = go_source
.split("const schema = `")
.nth(1)
.unwrap()
.split('`')
.next()
.unwrap(); .unwrap();
db.execute_batch(schema).unwrap();
db.execute_batch( db.execute_batch(
"INSERT INTO users VALUES ('go-user','go@example.com','existing hash','2026-01-01T00:00:00.123456789Z'); "INSERT INTO users VALUES ('go-user','go@example.com','existing hash','2026-01-01T00:00:00.123456789Z');
INSERT INTO refresh_tokens(id,token_hash,user_id,expires_at,created_at) VALUES (7,'existing-token-hash','go-user','2099-01-01T00:00:00Z','2026-01-01T00:00:00Z'); INSERT INTO refresh_tokens(id,token_hash,user_id,expires_at,created_at) VALUES (7,'existing-token-hash','go-user','2099-01-01T00:00:00Z','2026-01-01T00:00:00Z');
@@ -162,6 +210,26 @@ mod tests {
.unwrap(), .unwrap(),
"{\"amount\":12.5}" "{\"amount\":12.5}"
); );
// Contract v2 tables are added next to the untouched v1 tables.
for table in [
"ledgers",
"ledger_members",
"ledger_invites",
"ledger_records",
"ledger_changes",
"attachment_blobs",
] {
assert_eq!(
db.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?",
[table],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1,
"{table}"
);
}
db.execute("INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES ('go-user','transaction','next',NULL,'now',1,42)", []).unwrap(); db.execute("INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES ('go-user','transaction','next',NULL,'now',1,42)", []).unwrap();
assert_eq!(db.last_insert_rowid(), 42); assert_eq!(db.last_insert_rowid(), 42);
assert_eq!( assert_eq!(
+365
View File
@@ -0,0 +1,365 @@
//! Shared ledgers (contract v2, section 7).
//!
//! A ledger is a data space with its own records, change stream and
//! attachments. Membership is checked for every request that selects it.
use crate::sync::Space;
use chrono::{SecondsFormat, TimeDelta, Utc};
use rusqlite::{Connection, OptionalExtension, params};
use serde_json::{Value, json};
pub type LedgerError = (u16, &'static str, &'static str);
pub const INVITE_TTL: TimeDelta = TimeDelta::days(7);
pub const CODE_LENGTH: usize = 10;
/// 32 symbols without look-alikes (no I, O, 0, 1): five random bits each.
const CODE_ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const MAX_NAME: usize = 100;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Owner,
Member,
}
impl Role {
fn as_str(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::Member => "member",
}
}
}
fn internal<E>(_: E) -> LedgerError {
(
500,
"internal_error",
"Could not process the ledger request",
)
}
fn not_found() -> LedgerError {
(404, "not_found", "Ledger not found")
}
fn forbidden() -> LedgerError {
(403, "forbidden", "Not allowed for this ledger")
}
fn invalid_code() -> LedgerError {
(400, "invalid_code", "Invite code is invalid or expired")
}
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true)
}
/// Role of `user` in `ledger`: 404 for an unknown ledger, 403 for a non-member.
pub fn role(db: &Connection, ledger: &str, user: &str) -> Result<Role, LedgerError> {
let exists = db
.query_row("SELECT 1 FROM ledgers WHERE id=?", [ledger], |_| Ok(()))
.optional()
.map_err(internal)?
.is_some();
if !exists {
return Err(not_found());
}
let role: Option<String> = db
.query_row(
"SELECT role FROM ledger_members WHERE ledger_id=? AND user_id=?",
params![ledger, user],
|r| r.get(0),
)
.optional()
.map_err(internal)?;
match role.as_deref() {
Some("owner") => Ok(Role::Owner),
Some(_) => Ok(Role::Member),
None => Err(forbidden()),
}
}
/// Data space selected by the `X-Balance-Ledger` header value.
pub fn space(db: &Connection, header: &str, user: &str) -> Result<Space, LedgerError> {
let ledger = header.trim();
if ledger.is_empty() {
return Ok(Space::User(user.to_owned()));
}
role(db, ledger, user)?;
Ok(Space::Ledger(ledger.to_owned()))
}
fn describe(db: &Connection, ledger: &str, role: Role) -> Result<Value, LedgerError> {
let name: String = db
.query_row("SELECT name FROM ledgers WHERE id=?", [ledger], |r| {
r.get(0)
})
.map_err(internal)?;
let mut stmt = db
.prepare(
"SELECT m.user_id,u.email,m.role FROM ledger_members m JOIN users u ON u.id=m.user_id
WHERE m.ledger_id=? ORDER BY m.role='owner' DESC, m.joined_at, m.user_id",
)
.map_err(internal)?;
let members = stmt
.query_map([ledger], |r| {
Ok(json!({"userId": r.get::<_, String>(0)?, "email": r.get::<_, String>(1)?, "role": r.get::<_, String>(2)?}))
})
.map_err(internal)?
.collect::<rusqlite::Result<Vec<Value>>>()
.map_err(internal)?;
Ok(json!({"id": ledger, "name": name, "role": role.as_str(), "members": members}))
}
/// Shared ledgers of `user`; the personal space is not listed.
pub fn list(db: &Connection, user: &str) -> Result<Value, LedgerError> {
let mut stmt = db
.prepare(
"SELECT l.id,m.role FROM ledger_members m JOIN ledgers l ON l.id=m.ledger_id
WHERE m.user_id=? ORDER BY l.created_at, l.id",
)
.map_err(internal)?;
let rows = stmt
.query_map([user], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)? == "owner"))
})
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
let ledgers = rows
.into_iter()
.map(|(id, owner)| describe(db, &id, if owner { Role::Owner } else { Role::Member }))
.collect::<Result<Vec<_>, _>>()?;
Ok(json!({ "ledgers": ledgers }))
}
/// Ledger name from a `{"name": …}` body: trimmed, 1–100 characters.
pub fn parse_name(body: &[u8]) -> Result<String, LedgerError> {
let value: Value = serde_json::from_slice(body)
.map_err(|_| (400, "invalid_json", "Request body is not valid JSON"))?;
let name = value
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("");
if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) {
return Err((
400,
"invalid_name",
"Ledger name must contain 1 to 100 characters",
));
}
Ok(name.to_owned())
}
pub fn create(db: &mut Connection, user: &str, name: &str) -> Result<Value, LedgerError> {
let id = uuid_v4().map_err(internal)?;
let tx = db.transaction().map_err(internal)?;
let created = now();
tx.execute(
"INSERT INTO ledgers(id,name,owner_id,created_at) VALUES(?,?,?,?)",
params![id, name, user, created],
)
.map_err(internal)?;
tx.execute(
"INSERT INTO ledger_members(ledger_id,user_id,role,joined_at) VALUES(?,?,'owner',?)",
params![id, user, created],
)
.map_err(internal)?;
let ledger = describe(&tx, &id, Role::Owner)?;
tx.commit().map_err(internal)?;
Ok(ledger)
}
pub fn rename(db: &Connection, ledger: &str, user: &str, name: &str) -> Result<Value, LedgerError> {
if role(db, ledger, user)? != Role::Owner {
return Err(forbidden());
}
db.execute(
"UPDATE ledgers SET name=? WHERE id=?",
params![name, ledger],
)
.map_err(internal)?;
describe(db, ledger, Role::Owner)
}
/// Delete a ledger with its records, changes, invites and blob rows. The
/// caller removes the blob directory afterwards.
pub fn delete(db: &mut Connection, ledger: &str, user: &str) -> Result<(), LedgerError> {
if role(db, ledger, user)? != Role::Owner {
return Err(forbidden());
}
let tx = db.transaction().map_err(internal)?;
tx.execute(
"DELETE FROM attachment_blobs WHERE space=?",
[Space::Ledger(ledger.to_owned()).key()],
)
.map_err(internal)?;
// Cascades to members, invites, records and changes.
tx.execute("DELETE FROM ledgers WHERE id=?", [ledger])
.map_err(internal)?;
tx.commit().map_err(internal)
}
pub fn invite(db: &Connection, ledger: &str, user: &str) -> Result<Value, LedgerError> {
if role(db, ledger, user)? != Role::Owner {
return Err(forbidden());
}
let code = random_code().map_err(internal)?;
let created = Utc::now();
let expires = created + INVITE_TTL;
db.execute(
"INSERT INTO ledger_invites(code_hash,ledger_id,created_by,expires_at,created_at) VALUES(?,?,?,?,?)",
params![
crate::auth::token_hash(&code),
ledger,
user,
expires.to_rfc3339_opts(SecondsFormat::Nanos, true),
created.to_rfc3339_opts(SecondsFormat::Nanos, true)
],
)
.map_err(internal)?;
Ok(json!({
"code": code,
"expiresAt": expires.to_rfc3339_opts(SecondsFormat::Secs, true)
}))
}
/// Canonical invite code: case-insensitive, spaces and dashes ignored.
pub fn normalize_code(code: &str) -> Option<String> {
let code: String = code
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.map(|c| c.to_ascii_uppercase())
.collect();
(code.len() == CODE_LENGTH && code.bytes().all(|b| CODE_ALPHABET.contains(&b))).then_some(code)
}
pub fn join(db: &mut Connection, user: &str, body: &[u8]) -> Result<Value, LedgerError> {
let value: Value = serde_json::from_slice(body)
.map_err(|_| (400, "invalid_json", "Request body is not valid JSON"))?;
let code = value
.get("code")
.and_then(Value::as_str)
.and_then(normalize_code)
.ok_or_else(invalid_code)?;
let tx = db.transaction().map_err(internal)?;
let invite: Option<(String, String, Option<String>)> = tx
.query_row(
"SELECT ledger_id,expires_at,used_at FROM ledger_invites WHERE code_hash=?",
[crate::auth::token_hash(&code)],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()
.map_err(internal)?;
let Some((ledger, expires, used)) = invite else {
return Err(invalid_code());
};
if used.is_some() || expires <= now() {
return Err(invalid_code());
}
// Joining a ledger one already belongs to does not consume the code.
if let Ok(existing) = role(&tx, &ledger, user) {
return describe(&tx, &ledger, existing);
}
let joined = now();
tx.execute(
"UPDATE ledger_invites SET used_at=?, used_by=? WHERE code_hash=?",
params![joined, user, crate::auth::token_hash(&code)],
)
.map_err(internal)?;
tx.execute(
"INSERT INTO ledger_members(ledger_id,user_id,role,joined_at) VALUES(?,?,'member',?)",
params![ledger, user, joined],
)
.map_err(internal)?;
let result = describe(&tx, &ledger, Role::Member)?;
tx.commit().map_err(internal)?;
Ok(result)
}
/// The owner removes a member; a member removes only itself.
pub fn remove_member(
db: &Connection,
ledger: &str,
user: &str,
target: &str,
) -> Result<(), LedgerError> {
let caller = role(db, ledger, user)?;
if caller == Role::Member && target != user {
return Err(forbidden());
}
let target_role = match role(db, ledger, target) {
Ok(r) => r,
Err((403, _, _)) => return Err((404, "not_found", "Member not found")),
Err(e) => return Err(e),
};
if target_role == Role::Owner {
// Leaving is impossible while the ledger has an owner; the owner
// deletes the ledger instead.
return Err((
409,
"owner_cannot_leave",
"The owner cannot leave the ledger; delete it instead",
));
}
db.execute(
"DELETE FROM ledger_members WHERE ledger_id=? AND user_id=?",
params![ledger, target],
)
.map_err(internal)?;
Ok(())
}
fn random_code() -> Result<String, String> {
let mut bytes = [0u8; CODE_LENGTH];
getrandom(&mut bytes)?;
Ok(bytes
.iter()
.map(|b| CODE_ALPHABET[usize::from(b & 31)] as char)
.collect())
}
fn uuid_v4() -> Result<String, String> {
let mut b = [0u8; 16];
getrandom(&mut b)?;
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let hex: String = b.iter().map(|v| format!("{v:02x}")).collect();
Ok(format!(
"{}-{}-{}-{}-{}",
&hex[..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..]
))
}
fn getrandom(bytes: &mut [u8]) -> Result<(), String> {
use rand::RngCore;
rand::rngs::OsRng
.try_fill_bytes(bytes)
.map_err(|_| "random source unavailable".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_are_ten_unambiguous_symbols_and_normalize() {
let code = random_code().unwrap();
assert_eq!(code.len(), CODE_LENGTH);
assert_eq!(normalize_code(&code), Some(code.clone()));
let lower = format!(
" {}-{} ",
code[..5].to_lowercase(),
code[5..].to_lowercase()
);
assert_eq!(normalize_code(&lower), Some(code));
assert_eq!(normalize_code("ABCDEFGHI0"), None);
assert_eq!(normalize_code("ABCDEFGHJ"), None);
let id = uuid_v4().unwrap();
assert_eq!(id.len(), 36);
assert_eq!(&id[14..15], "4");
}
}
+2
View File
@@ -1,7 +1,9 @@
mod attachments;
mod auth; mod auth;
mod config; mod config;
mod database; mod database;
mod go_json; mod go_json;
mod ledgers;
mod runtime; mod runtime;
mod server; mod server;
mod sync; mod sync;
+302 -36
View File
@@ -1,4 +1,4 @@
use crate::{auth, config::Config, sync}; use crate::{attachments, auth, config::Config, ledgers, sync};
use axum::{ use axum::{
body::{Body, HttpBody}, body::{Body, HttpBody},
extract::{ConnectInfo, State as ExtractState}, extract::{ConnectInfo, State as ExtractState},
@@ -24,12 +24,14 @@ pub struct State {
cfg: Config, cfg: Config,
db: Mutex<Connection>, db: Mutex<Connection>,
tokens: auth::TokenManager, tokens: auth::TokenManager,
attachments: attachments::Store,
clients: Mutex<HashMap<String, (Instant, u64)>>, clients: Mutex<HashMap<String, (Instant, u64)>>,
} }
impl State { impl State {
pub fn new(cfg: Config, db: Mutex<Connection>) -> Self { pub fn new(cfg: Config, db: Mutex<Connection>) -> Self {
Self { Self {
tokens: auth::TokenManager::new(&cfg.jwt_secret, cfg.access_ttl), tokens: auth::TokenManager::new(&cfg.jwt_secret, cfg.access_ttl),
attachments: attachments::Store::new(cfg.attachments_dir.clone()),
cfg, cfg,
db, db,
clients: Mutex::new(HashMap::new()), clients: Mutex::new(HashMap::new()),
@@ -62,8 +64,17 @@ pub async fn handle(
}; };
redirect(&location, &method) redirect(&location, &method)
} else { } else {
// Bound memory independently of Content-Length, including chunked requests. // Bound memory independently of Content-Length, including chunked
let body = read_body(body).await; // requests. Only an authorized attachment upload may exceed 2 MiB.
let limit = if method == Method::PUT
&& percent_decode(&path).starts_with("/v1/attachments/")
&& authorize(&state, &headers).is_ok()
{
attachments::MAX_BYTES + 1
} else {
2 << 20
};
let body = read_body(body, limit).await;
let state = state.clone(); let state = state.clone();
let headers = headers.clone(); let headers = headers.clone();
let escaped_slash = path.to_ascii_lowercase().contains("%2f"); let escaped_slash = path.to_ascii_lowercase().contains("%2f");
@@ -134,13 +145,25 @@ pub async fn handle(
h.insert("Access-Control-Allow-Origin", value); h.insert("Access-Control-Allow-Origin", value);
} }
h.insert("Vary", HeaderValue::from_static("Origin")); h.insert("Vary", HeaderValue::from_static("Origin"));
// v1 responses keep the recorded Go headers; contract v2 routes and
// real preflights (which v1 browsers also send) get the superset.
let extended = is_v2_route(&percent_decode(&path))
|| headers.contains_key("access-control-request-method");
h.insert( h.insert(
"Access-Control-Allow-Headers", "Access-Control-Allow-Headers",
HeaderValue::from_static("Authorization, Content-Type"), HeaderValue::from_static(if extended {
"Authorization, Content-Type, X-Balance-Ledger"
} else {
"Authorization, Content-Type"
}),
); );
h.insert( h.insert(
"Access-Control-Allow-Methods", "Access-Control-Allow-Methods",
HeaderValue::from_static("GET, POST, OPTIONS"), HeaderValue::from_static(if extended {
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
} else {
"GET, POST, OPTIONS"
}),
); );
} }
if is_head { if is_head {
@@ -151,16 +174,16 @@ pub async fn handle(
// Go's decoder consumes one value, so a valid first value may be followed by // Go's decoder consumes one value, so a valid first value may be followed by
// ignored data beyond MaxBytesReader's cap. Keep the bounded prefix available. // ignored data beyond MaxBytesReader's cap. Keep the bounded prefix available.
async fn read_body(mut body: Body) -> Result<Vec<u8>, ()> { async fn read_body(mut body: Body, limit: usize) -> Result<Vec<u8>, ()> {
let mut bytes = Vec::new(); let mut bytes = Vec::new();
while bytes.len() < 2 << 20 { while bytes.len() < limit {
let frame = std::future::poll_fn(|cx| std::pin::Pin::new(&mut body).poll_frame(cx)).await; let frame = std::future::poll_fn(|cx| std::pin::Pin::new(&mut body).poll_frame(cx)).await;
let Some(frame) = frame else { let Some(frame) = frame else {
break; break;
}; };
let frame = frame.map_err(|_| ())?; let frame = frame.map_err(|_| ())?;
if let Ok(data) = frame.into_data() { if let Ok(data) = frame.into_data() {
let remaining = (2 << 20) - bytes.len(); let remaining = limit - bytes.len();
bytes.extend_from_slice(&data[..data.len().min(remaining)]); bytes.extend_from_slice(&data[..data.len().min(remaining)]);
} }
} }
@@ -175,6 +198,9 @@ fn dispatch(
body: Result<&[u8], &()>, body: Result<&[u8], &()>,
host: &str, host: &str,
) -> Response { ) -> Response {
if let Some(response) = dispatch_v2(state, method, path, headers, body, host) {
return response;
}
let post_route = matches!( let post_route = matches!(
path, path,
"/v1/auth/register" "/v1/auth/register"
@@ -208,21 +234,10 @@ fn dispatch(
if matches!( if matches!(
path, path,
"/v1/auth/register" | "/v1/auth/login" | "/v1/auth/refresh" "/v1/auth/register" | "/v1/auth/login" | "/v1/auth/refresh"
) { ) && let Some(r) = rate_limited(state, host)
let now = Instant::now(); {
let mut clients = state.clients.lock().unwrap_or_else(|e| e.into_inner());
let bucket = clients.entry(host.to_owned()).or_insert((now, 0));
if now.duration_since(bucket.0) >= Duration::from_secs(60) {
*bucket = (now, 0);
}
bucket.1 = bucket.1.saturating_add(1);
if bucket.1 > 30 {
let mut r = error(429, "rate_limited", "Too many authentication requests");
r.headers_mut()
.insert("Retry-After", HeaderValue::from_static("60"));
return r; return r;
} }
}
let claims = if path == "/v1/sync" { let claims = if path == "/v1/sync" {
match authorize(state, headers) { match authorize(state, headers) {
Ok(c) => Some(c), Ok(c) => Some(c),
@@ -238,12 +253,17 @@ fn dispatch(
return invalid_json(); return invalid_json();
}; };
if let Some(claims) = claims { if let Some(claims) = claims {
return match sync::synchronize_wire( let mut db = state.db.lock().unwrap_or_else(|e| e.into_inner());
&mut state.db.lock().unwrap_or_else(|e| e.into_inner()), let space = match ledgers::space(&db, header(headers, LEDGER_HEADER), &claims.subject) {
&claims.subject, Ok(space) => space,
body, Err((status, code, message)) => return error(status, code, message),
) { };
Ok(value) => raw_json_response(200, value), return match sync::synchronize_space(&mut db, &space, body) {
Ok((value, garbage)) => {
// Still under the database lock, so no upload can race.
state.attachments.remove(&space, &garbage);
raw_json_response(200, value)
}
Err((status, code, message)) => error(status, code, &message), Err((status, code, message)) => error(status, code, &message),
}; };
} }
@@ -280,6 +300,248 @@ fn dispatch(
} }
} }
const LEDGER_HEADER: &str = "x-balance-ledger";
/// Shared limiter of endpoints that accept guessable secrets.
fn rate_limited(state: &State, host: &str) -> Option<Response> {
let now = Instant::now();
let mut clients = state.clients.lock().unwrap_or_else(|e| e.into_inner());
let bucket = clients.entry(host.to_owned()).or_insert((now, 0));
if now.duration_since(bucket.0) >= Duration::from_secs(60) {
*bucket = (now, 0);
}
bucket.1 = bucket.1.saturating_add(1);
if bucket.1 > 30 {
let mut r = error(429, "rate_limited", "Too many authentication requests");
r.headers_mut()
.insert("Retry-After", HeaderValue::from_static("60"));
return Some(r);
}
None
}
enum Route<'a> {
Capabilities,
Ledgers,
Join,
Ledger(&'a str),
Invites(&'a str),
Member(&'a str, &'a str),
Attachment(&'a str),
}
fn route(path: &str) -> Option<Route<'_>> {
let rest = path.strip_prefix("/v1/")?;
let segments: Vec<&str> = rest.split('/').collect();
if segments.iter().any(|s| s.is_empty()) {
return None;
}
Some(match segments.as_slice() {
["capabilities"] => Route::Capabilities,
["ledgers"] => Route::Ledgers,
["ledgers", "join"] => Route::Join,
["ledgers", id] => Route::Ledger(id),
["ledgers", id, "invites"] => Route::Invites(id),
["ledgers", id, "members", user] => Route::Member(id, user),
["attachments", sha] => Route::Attachment(sha),
_ => return None,
})
}
fn is_v2_route(path: &str) -> bool {
route(path).is_some()
}
fn method_not_allowed(allow: &'static str) -> Response {
let mut r = plain(405, "Method Not Allowed\n");
r.headers_mut()
.insert("Allow", HeaderValue::from_static(allow));
r
}
fn ledger_error((status, code, message): ledgers::LedgerError) -> Response {
error(status, code, message)
}
/// Contract v2 endpoints. `None` hands the request to the v1 dispatcher.
fn dispatch_v2(
state: &State,
method: &Method,
path: &str,
headers: &HeaderMap,
body: Result<&[u8], &()>,
host: &str,
) -> Option<Response> {
let route = route(path)?;
let get = method == Method::GET || method == Method::HEAD;
let allowed = match route {
Route::Capabilities => get,
Route::Ledgers => get || method == Method::POST,
Route::Join | Route::Invites(_) => method == Method::POST,
Route::Ledger(_) => method == Method::PATCH || method == Method::DELETE,
Route::Member(..) => method == Method::DELETE,
Route::Attachment(_) => get || method == Method::PUT,
};
if !allowed {
return Some(method_not_allowed(match route {
Route::Capabilities => "GET, HEAD",
Route::Ledgers => "GET, HEAD, POST",
Route::Join | Route::Invites(_) => "POST",
Route::Ledger(_) => "PATCH, DELETE",
Route::Member(..) => "DELETE",
Route::Attachment(_) => "GET, HEAD, PUT",
}));
}
if let Route::Capabilities = route {
return Some(json_response(200, capabilities()));
}
if let Route::Join = route
&& let Some(r) = rate_limited(state, host)
{
return Some(r);
}
let claims = match authorize(state, headers) {
Ok(c) => c,
Err(r) => return Some(*r),
};
let user = claims.subject.as_str();
let Ok(body) = body else {
return Some(invalid_json());
};
let lock = || state.db.lock().unwrap_or_else(|e| e.into_inner());
Some(match route {
Route::Capabilities => unreachable!("answered above"),
Route::Ledgers if get => match ledgers::list(&lock(), user) {
Ok(v) => json_response(200, v),
Err(e) => ledger_error(e),
},
Route::Ledgers => match ledgers::parse_name(body)
.and_then(|name| ledgers::create(&mut lock(), user, &name))
{
Ok(v) => json_response(201, v),
Err(e) => ledger_error(e),
},
Route::Join => match ledgers::join(&mut lock(), user, body) {
Ok(v) => json_response(200, v),
Err(e) => ledger_error(e),
},
Route::Ledger(id) if method == Method::PATCH => {
let db = lock();
match ledgers::role(&db, id, user)
.and_then(|_| ledgers::parse_name(body))
.and_then(|name| ledgers::rename(&db, id, user, &name))
{
Ok(v) => json_response(200, v),
Err(e) => ledger_error(e),
}
}
Route::Ledger(id) => {
let mut db = lock();
match ledgers::delete(&mut db, id, user) {
Ok(()) => {
// Still under the lock: no upload can recreate the directory.
state
.attachments
.remove_space(&sync::Space::Ledger(id.to_owned()));
empty(204)
}
Err(e) => ledger_error(e),
}
}
Route::Invites(id) => match ledgers::invite(&lock(), id, user) {
Ok(v) => json_response(201, v),
Err(e) => ledger_error(e),
},
Route::Member(id, target) => match ledgers::remove_member(&lock(), id, user, target) {
Ok(()) => empty(204),
Err(e) => ledger_error(e),
},
Route::Attachment(sha) => {
let space = match ledgers::space(&lock(), header(headers, LEDGER_HEADER), user) {
Ok(space) => space,
Err(e) => return Some(ledger_error(e)),
};
if get {
get_attachment(state, &space, sha)
} else {
put_attachment(state, &space, sha, header(headers, "content-type"), body)
}
}
})
}
fn capabilities() -> Value {
json!({
"contract": 2,
"entities": sync::ENTITIES,
"attachments": {"maxBytes": attachments::MAX_BYTES, "types": attachments::TYPES},
"ledgers": true
})
}
fn put_attachment(
state: &State,
space: &sync::Space,
sha: &str,
content_type: &str,
body: &[u8],
) -> Response {
use attachments::PutError;
match attachments::put(
&state.db,
&state.attachments,
space,
sha,
content_type,
body,
) {
Ok(created) => json_response(
if created { 201 } else { 200 },
json!({"sha256": sha, "size": body.len(), "contentType": attachments::media_type(content_type)}),
),
Err(PutError::HashMismatch) => error(
400,
"hash_mismatch",
"SHA-256 of the body does not match the path",
),
Err(PutError::UnsupportedMediaType) => error(
415,
"unsupported_media_type",
"Content-Type is not an accepted attachment type",
),
Err(PutError::PayloadTooLarge) => error(
413,
"payload_too_large",
"Attachment exceeds the maximum size",
),
Err(PutError::Internal) => error(500, "internal_error", "Could not store attachment"),
}
}
fn get_attachment(state: &State, space: &sync::Space, sha: &str) -> Response {
match attachments::get(&state.db, &state.attachments, space, sha) {
Ok(Some((bytes, media))) => {
let mut r = empty(200);
if let Ok(v) = HeaderValue::from_str(&media) {
r.headers_mut().insert("Content-Type", v);
}
r.headers_mut().insert(
"Content-Length",
HeaderValue::from_str(&bytes.len().to_string()).unwrap(),
);
// Never render user files inline as part of the web client origin.
r.headers_mut().insert(
"Content-Disposition",
HeaderValue::from_static("attachment"),
);
*r.body_mut() = Body::from(bytes);
r
}
Ok(None) => error(404, "not_found", "Attachment not found"),
Err(()) => error(500, "internal_error", "Could not read attachment"),
}
}
#[derive(Default)] #[derive(Default)]
struct Input { struct Input {
email: String, email: String,
@@ -687,27 +949,24 @@ fn web(path: &str, headers: &HeaderMap) -> Response {
} }
let (bytes, mime): (&'static [u8], &'static str) = match cleaned.as_str() { let (bytes, mime): (&'static [u8], &'static str) = match cleaned.as_str() {
"/" => ( "/" => (
include_bytes!("../internal/httpapi/web/index.html"), include_bytes!("../web/index.html"),
"text/html; charset=utf-8", "text/html; charset=utf-8",
), ),
"/app.js" => ( "/app.js" => (
include_bytes!("../internal/httpapi/web/app.js"), include_bytes!("../web/app.js"),
"text/javascript; charset=utf-8", "text/javascript; charset=utf-8",
), ),
"/service-worker.js" => ( "/service-worker.js" => (
include_bytes!("../internal/httpapi/web/service-worker.js"), include_bytes!("../web/service-worker.js"),
"text/javascript; charset=utf-8", "text/javascript; charset=utf-8",
), ),
"/styles.css" => ( "/styles.css" => (
include_bytes!("../internal/httpapi/web/styles.css"), include_bytes!("../web/styles.css"),
"text/css; charset=utf-8", "text/css; charset=utf-8",
), ),
"/icon.svg" => ( "/icon.svg" => (include_bytes!("../web/icon.svg"), "image/svg+xml"),
include_bytes!("../internal/httpapi/web/icon.svg"),
"image/svg+xml",
),
"/manifest.webmanifest" => ( "/manifest.webmanifest" => (
include_bytes!("../internal/httpapi/web/manifest.webmanifest"), include_bytes!("../web/manifest.webmanifest"),
"text/plain; charset=utf-8", "text/plain; charset=utf-8",
), ),
_ => return plain(404, "404 page not found\n"), _ => return plain(404, "404 page not found\n"),
@@ -871,6 +1130,10 @@ mod tests {
refresh_ttl: chrono::TimeDelta::days(30), refresh_ttl: chrono::TimeDelta::days(30),
allow_registration: true, allow_registration: true,
cors_origins: vec!["https://allowed.example".into()], cors_origins: vec!["https://allowed.example".into()],
attachments_dir: std::env::temp_dir().join(format!(
"balance-attachments-{}",
auth::identifier().unwrap()
)),
}, },
Mutex::new(crate::database::open(":memory:").unwrap()), Mutex::new(crate::database::open(":memory:").unwrap()),
) )
@@ -1013,3 +1276,6 @@ mod tests {
); );
} }
} }
#[cfg(test)]
mod v2_tests;
+892
View File
@@ -0,0 +1,892 @@
//! Endpoint tests for contract v2: capabilities, ledgers and attachments.
use super::*;
use sha2::{Digest, Sha256};
struct Env {
state: State,
dir: std::path::PathBuf,
}
impl Drop for Env {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn env() -> Env {
let dir = std::env::temp_dir().join(format!("balance-v2-test-{}", auth::identifier().unwrap()));
let state = State::new(
Config {
address: "127.0.0.1:0".into(),
database_path: ":memory:".into(),
jwt_secret: "test secret".into(),
access_ttl: chrono::TimeDelta::minutes(15),
refresh_ttl: chrono::TimeDelta::days(30),
allow_registration: true,
cors_origins: vec!["https://allowed.example".into()],
attachments_dir: dir.clone(),
},
Mutex::new(crate::database::open(":memory:").unwrap()),
);
{
let db = state.db.lock().unwrap();
for user in ["owner", "member", "stranger"] {
db.execute(
"INSERT INTO users VALUES(?,?,'hash','now')",
params![user, format!("{user}@example.com")],
)
.unwrap();
}
}
Env { state, dir }
}
fn auth_headers(env: &Env, user: &str, ledger: Option<&str>) -> HeaderMap {
let (token, _) = env
.state
.tokens
.access_token(user, &format!("{user}@example.com"))
.unwrap();
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
);
if let Some(ledger) = ledger {
headers.insert("X-Balance-Ledger", HeaderValue::from_str(ledger).unwrap());
}
headers
}
fn call(
env: &Env,
method: &str,
path: &str,
user: Option<&str>,
ledger: Option<&str>,
body: &[u8],
) -> (u16, Value) {
let headers = match user {
Some(user) => auth_headers(env, user, ledger),
None => HeaderMap::new(),
};
request(env, method, path, &headers, body)
}
fn request(env: &Env, method: &str, path: &str, headers: &HeaderMap, body: &[u8]) -> (u16, Value) {
let r = dispatch(
&env.state,
&Method::from_bytes(method.as_bytes()).unwrap(),
path,
headers,
Ok(body),
"test-host",
);
let status = r.status().as_u16();
let bytes = futures_body(r);
(
status,
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
)
}
fn futures_body(r: Response) -> Vec<u8> {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(axum::body::to_bytes(r.into_body(), usize::MAX))
.unwrap()
.to_vec()
}
fn code_of(v: &Value) -> &str {
v["error"]["code"].as_str().unwrap_or("")
}
fn create_ledger(env: &Env) -> String {
let (status, v) = call(
env,
"POST",
"/v1/ledgers",
Some("owner"),
None,
br#"{"name":" Family "}"#,
);
assert_eq!(status, 201);
v["id"].as_str().unwrap().to_owned()
}
fn join_member(env: &Env, ledger: &str) {
let (_, invite) = call(
env,
"POST",
&format!("/v1/ledgers/{ledger}/invites"),
Some("owner"),
None,
b"",
);
let body = json!({"code": invite["code"]}).to_string();
let (status, _) = call(
env,
"POST",
"/v1/ledgers/join",
Some("member"),
None,
body.as_bytes(),
);
assert_eq!(status, 200);
}
fn sha(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn put(
env: &Env,
user: &str,
ledger: Option<&str>,
hash: &str,
ty: &str,
bytes: &[u8],
) -> (u16, Value) {
let mut headers = auth_headers(env, user, ledger);
headers.insert("Content-Type", HeaderValue::from_str(ty).unwrap());
request(
env,
"PUT",
&format!("/v1/attachments/{hash}"),
&headers,
bytes,
)
}
fn get_raw(env: &Env, user: &str, ledger: Option<&str>, hash: &str) -> Response {
dispatch(
&env.state,
&Method::GET,
&format!("/v1/attachments/{hash}"),
&auth_headers(env, user, ledger),
Ok(b""),
"test-host",
)
}
fn sync_call(env: &Env, user: &str, ledger: Option<&str>, body: Value) -> (u16, Value) {
call(
env,
"POST",
"/v1/sync",
Some(user),
ledger,
body.to_string().as_bytes(),
)
}
fn attachment_change(id: &str, hash: &str, time: &str, deleted: bool) -> Value {
json!({"entity":"attachment","id":id,"updatedAt":time,"deleted":deleted,
"payload":{"transactionId":"t","sha256":hash,"contentType":"image/png","size":3,"name":"a.png","createdAt":time}})
}
#[test]
fn capabilities_are_public_and_match_contract() {
let env = env();
let (status, v) = call(&env, "GET", "/v1/capabilities", None, None, b"");
assert_eq!(status, 200);
assert_eq!(
v,
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
})
);
let r = dispatch(
&env.state,
&Method::POST,
"/v1/capabilities",
&HeaderMap::new(),
Ok(b""),
"h",
);
assert_eq!(r.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(r.headers()["Allow"], "GET, HEAD");
}
#[test]
fn sync_accepts_every_v2_entity_and_still_rejects_unknown_ones() {
let env = env();
let changes: Vec<Value> = sync::ENTITIES
.iter()
.map(|e| json!({"entity":e,"id":format!("{e}-1"),"updatedAt":"2026-01-01T00:00:00Z","payload":{"x":1}}))
.collect();
let (status, v) = sync_call(&env, "owner", None, json!({ "changes": changes }));
assert_eq!(status, 200);
assert_eq!(v["changes"].as_array().unwrap().len(), 11);
let (status, v) = sync_call(
&env,
"owner",
None,
json!({"changes":[{"entity":"wallet","id":"w","updatedAt":"2026-01-01T00:00:00Z","payload":{}}]}),
);
assert_eq!(status, 400);
assert_eq!(code_of(&v), "invalid_change");
}
#[test]
fn v2_endpoints_require_authorization() {
let env = env();
for (method, path) in [
("GET", "/v1/ledgers"),
("POST", "/v1/ledgers"),
("POST", "/v1/ledgers/join"),
("PATCH", "/v1/ledgers/x"),
("DELETE", "/v1/ledgers/x"),
("POST", "/v1/ledgers/x/invites"),
("DELETE", "/v1/ledgers/x/members/y"),
("GET", "/v1/attachments/x"),
("PUT", "/v1/attachments/x"),
] {
let (status, v) = call(&env, method, path, None, None, b"{}");
assert_eq!(status, 401, "{method} {path}");
assert_eq!(code_of(&v), "authorization_required");
}
let (status, _) = call(&env, "PUT", "/v1/ledgers", Some("owner"), None, b"");
assert_eq!(status, 405);
let (status, _) = call(&env, "GET", "/v1/ledgers/join", Some("owner"), None, b"");
assert_eq!(status, 405);
}
#[test]
fn ledger_lifecycle_roles_and_error_codes() {
let env = env();
// Personal space is not listed.
let (status, v) = call(&env, "GET", "/v1/ledgers", Some("owner"), None, b"");
assert_eq!((status, v), (200, json!({"ledgers": []})));
for bad in [&br#"{"name":" "}"#[..], br#"{}"#] {
let (status, v) = call(&env, "POST", "/v1/ledgers", Some("owner"), None, bad);
assert_eq!((status, code_of(&v)), (400, "invalid_name"));
}
let (status, v) = call(&env, "POST", "/v1/ledgers", Some("owner"), None, b"{");
assert_eq!((status, code_of(&v)), (400, "invalid_json"));
let id = create_ledger(&env);
let (_, v) = call(&env, "GET", "/v1/ledgers", Some("owner"), None, b"");
assert_eq!(
v,
json!({"ledgers":[{"id":id,"name":"Family","role":"owner",
"members":[{"userId":"owner","email":"owner@example.com","role":"owner"}]}]})
);
let ledger = format!("/v1/ledgers/{id}");
let invites = format!("{ledger}/invites");
// Unknown ledger → 404, non-member → 403.
let (status, v) = call(
&env,
"PATCH",
"/v1/ledgers/missing",
Some("owner"),
None,
br#"{"name":"x"}"#,
);
assert_eq!((status, code_of(&v)), (404, "not_found"));
let (status, v) = call(
&env,
"PATCH",
&ledger,
Some("stranger"),
None,
br#"{"name":"x"}"#,
);
assert_eq!((status, code_of(&v)), (403, "forbidden"));
let (status, v) = call(&env, "POST", &invites, Some("stranger"), None, b"");
assert_eq!((status, code_of(&v)), (403, "forbidden"));
let (status, v) = call(
&env,
"PATCH",
&ledger,
Some("owner"),
None,
br#"{"name":"Home"}"#,
);
assert_eq!((status, v["name"].as_str()), (200, Some("Home")));
// Invites: 10 characters, 7 days, hashed at rest, single use.
let (status, invite) = call(&env, "POST", &invites, Some("owner"), None, b"");
assert_eq!(status, 201);
let code = invite["code"].as_str().unwrap().to_owned();
assert_eq!(code.len(), 10);
let expires = DateTime::parse_from_rfc3339(invite["expiresAt"].as_str().unwrap()).unwrap();
let days = (expires.with_timezone(&Utc) - Utc::now()).num_hours();
assert!((167..=168).contains(&days), "{days}");
{
let db = env.state.db.lock().unwrap();
let stored: String = db
.query_row("SELECT code_hash FROM ledger_invites", [], |r| r.get(0))
.unwrap();
assert_ne!(stored, code);
assert_eq!(stored, auth::token_hash(&code));
}
for bad in [
json!({"code":"AAAAAAAAAA"}),
json!({"code":"short"}),
json!({}),
] {
let (status, v) = call(
&env,
"POST",
"/v1/ledgers/join",
Some("member"),
None,
bad.to_string().as_bytes(),
);
assert_eq!((status, code_of(&v)), (400, "invalid_code"));
}
let lower = json!({"code": code.to_lowercase()}).to_string();
let (status, v) = call(
&env,
"POST",
"/v1/ledgers/join",
Some("member"),
None,
lower.as_bytes(),
);
assert_eq!(status, 200);
assert_eq!(v["role"], "member");
assert_eq!(v["members"].as_array().unwrap().len(), 2);
let (status, v) = call(
&env,
"POST",
"/v1/ledgers/join",
Some("stranger"),
None,
lower.as_bytes(),
);
assert_eq!((status, code_of(&v)), (400, "invalid_code"));
// Expired codes are rejected.
let (_, expired) = call(&env, "POST", &invites, Some("owner"), None, b"");
env.state
.db
.lock()
.unwrap()
.execute(
"UPDATE ledger_invites SET expires_at='2000-01-01T00:00:00.000000000Z' WHERE code_hash=?",
[auth::token_hash(expired["code"].as_str().unwrap())],
)
.unwrap();
let body = json!({"code": expired["code"]}).to_string();
let (status, v) = call(
&env,
"POST",
"/v1/ledgers/join",
Some("stranger"),
None,
body.as_bytes(),
);
assert_eq!((status, code_of(&v)), (400, "invalid_code"));
// Members may not administer the ledger.
let (status, v) = call(
&env,
"PATCH",
&ledger,
Some("member"),
None,
br#"{"name":"x"}"#,
);
assert_eq!((status, code_of(&v)), (403, "forbidden"));
let (status, v) = call(&env, "POST", &invites, Some("member"), None, b"");
assert_eq!((status, code_of(&v)), (403, "forbidden"));
let (status, v) = call(&env, "DELETE", &ledger, Some("member"), None, b"");
assert_eq!((status, code_of(&v)), (403, "forbidden"));
let (status, v) = call(
&env,
"DELETE",
&format!("{ledger}/members/owner"),
Some("member"),
None,
b"",
);
assert_eq!((status, code_of(&v)), (403, "forbidden"));
let (_, v) = call(&env, "GET", "/v1/ledgers", Some("member"), None, b"");
assert_eq!(v["ledgers"][0]["role"], "member");
// The owner cannot leave; unknown members are 404.
let (status, v) = call(
&env,
"DELETE",
&format!("{ledger}/members/owner"),
Some("owner"),
None,
b"",
);
assert_eq!((status, code_of(&v)), (409, "owner_cannot_leave"));
let (status, v) = call(
&env,
"DELETE",
&format!("{ledger}/members/stranger"),
Some("owner"),
None,
b"",
);
assert_eq!((status, code_of(&v)), (404, "not_found"));
// A member leaves; the owner removes a member.
let (status, _) = call(
&env,
"DELETE",
&format!("{ledger}/members/member"),
Some("member"),
None,
b"",
);
assert_eq!(status, 204);
let (_, v) = call(&env, "GET", "/v1/ledgers", Some("member"), None, b"");
assert_eq!(v, json!({"ledgers": []}));
join_member(&env, &id);
let (status, _) = call(
&env,
"DELETE",
&format!("{ledger}/members/member"),
Some("owner"),
None,
b"",
);
assert_eq!(status, 204);
let (status, v) = sync_call(&env, "member", Some(&id), json!({}));
assert_eq!((status, code_of(&v)), (403, "forbidden"));
let (status, _) = call(&env, "DELETE", &ledger, Some("owner"), None, b"");
assert_eq!(status, 204);
let (status, v) = call(&env, "DELETE", &ledger, Some("owner"), None, b"");
assert_eq!((status, code_of(&v)), (404, "not_found"));
let db = env.state.db.lock().unwrap();
for table in ["ledgers", "ledger_members", "ledger_invites"] {
let n: i64 = db
.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0, "{table}");
}
}
#[test]
fn joining_twice_keeps_the_code_and_the_role() {
let env = env();
let id = create_ledger(&env);
let (_, invite) = call(
&env,
"POST",
&format!("/v1/ledgers/{id}/invites"),
Some("owner"),
None,
b"",
);
let body = json!({"code": invite["code"]}).to_string();
let (status, v) = call(
&env,
"POST",
"/v1/ledgers/join",
Some("owner"),
None,
body.as_bytes(),
);
assert_eq!((status, v["role"].as_str()), (200, Some("owner")));
let (status, v) = call(
&env,
"POST",
"/v1/ledgers/join",
Some("member"),
None,
body.as_bytes(),
);
assert_eq!((status, v["role"].as_str()), (200, Some("member")));
}
#[test]
fn join_attempts_are_rate_limited() {
let env = env();
for _ in 0..30 {
let (status, _) = call(
&env,
"POST",
"/v1/ledgers/join",
Some("member"),
None,
br#"{"code":"x"}"#,
);
assert_eq!(status, 400);
}
let (status, v) = call(
&env,
"POST",
"/v1/ledgers/join",
Some("member"),
None,
br#"{"code":"x"}"#,
);
assert_eq!((status, code_of(&v)), (429, "rate_limited"));
}
#[test]
fn ledger_sync_is_shared_isolated_and_has_its_own_cursor() {
let env = env();
// Personal data first, so personal and ledger sequences differ.
let personal = json!({"changes":[{"entity":"category","id":"p","updatedAt":"2026-01-01T00:00:00Z","payload":{"name":"Mine"}}]});
let (_, v) = sync_call(&env, "owner", None, personal.clone());
assert_eq!(v["cursor"], 1);
let (_, v) = sync_call(&env, "owner", None, personal);
assert_eq!(v["cursor"], 1);
let id = create_ledger(&env);
join_member(&env, &id);
let shared = json!({"changes":[{"entity":"transaction","id":"t","updatedAt":"2026-01-02T00:00:00Z","payload":{"amount":10}}]});
let (status, v) = sync_call(&env, "owner", Some(&id), shared);
assert_eq!(status, 200);
assert_eq!(v["cursor"], 1);
assert_eq!(v["changes"][0]["id"], "t");
// The member sees the ledger record and nothing personal of the owner.
let (_, v) = sync_call(&env, "member", Some(&id), json!({}));
assert_eq!(v["changes"].as_array().unwrap().len(), 1);
assert_eq!(v["changes"][0]["entity"], "transaction");
// Last write wins among members.
let edit = json!({"cursor":1,"changes":[{"entity":"transaction","id":"t","updatedAt":"2026-01-03T00:00:00Z","payload":{"amount":20}}]});
let (_, v) = sync_call(&env, "member", Some(&id), edit);
assert_eq!(v["changes"][0]["version"], 2);
assert_eq!(v["cursor"], 2);
let (_, v) = sync_call(&env, "owner", Some(&id), json!({"cursor":1}));
assert_eq!(v["changes"][0]["payload"]["amount"], 20);
// Personal spaces stay untouched.
let (_, v) = sync_call(&env, "member", None, json!({}));
assert_eq!(
(v["cursor"].clone(), v["changes"].clone()),
(json!(0), json!([]))
);
let (_, v) = sync_call(&env, "owner", None, json!({}));
assert_eq!(v["changes"].as_array().unwrap().len(), 1);
assert_eq!(v["changes"][0]["id"], "p");
// An empty header means the personal space.
let (_, v) = sync_call(&env, "owner", Some(" "), json!({}));
assert_eq!(v["changes"][0]["id"], "p");
let (status, v) = sync_call(&env, "stranger", Some(&id), json!({}));
assert_eq!((status, code_of(&v)), (403, "forbidden"));
let (status, v) = sync_call(&env, "owner", Some("missing"), json!({}));
assert_eq!((status, code_of(&v)), (404, "not_found"));
// Deleting the ledger removes its records and changes.
call(
&env,
"DELETE",
&format!("/v1/ledgers/{id}"),
Some("owner"),
None,
b"",
);
let db = env.state.db.lock().unwrap();
for table in ["ledger_records", "ledger_changes"] {
let n: i64 = db
.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0, "{table}");
}
let n: i64 = db
.query_row("SELECT count(*) FROM records", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn attachment_upload_validation_and_idempotency() {
let env = env();
let bytes = b"\x89PNG fake";
let hash = sha(bytes);
let (status, v) = put(&env, "owner", None, &hash, "image/png", bytes);
assert_eq!(status, 201);
assert_eq!(v["sha256"], hash.as_str());
let (status, _) = put(&env, "owner", None, &hash, "image/png", bytes);
assert_eq!(status, 200);
let (status, v) = put(&env, "owner", None, &sha(b"other"), "image/png", bytes);
assert_eq!((status, code_of(&v)), (400, "hash_mismatch"));
let (status, v) = put(
&env,
"owner",
None,
&hash.to_uppercase(),
"image/png",
bytes,
);
assert_eq!((status, code_of(&v)), (400, "hash_mismatch"));
let (status, v) = put(&env, "owner", None, &hash, "image/gif", bytes);
assert_eq!((status, code_of(&v)), (415, "unsupported_media_type"));
let mut headers = auth_headers(&env, "owner", None);
let (status, v) = request(
&env,
"PUT",
&format!("/v1/attachments/{hash}"),
&headers,
bytes,
);
assert_eq!((status, code_of(&v)), (415, "unsupported_media_type"));
headers.insert("Content-Type", HeaderValue::from_static("application/pdf"));
let large = vec![0u8; attachments::MAX_BYTES + 1];
let (status, v) = request(
&env,
"PUT",
&format!("/v1/attachments/{}", sha(&large)),
&headers,
&large,
);
assert_eq!((status, code_of(&v)), (413, "payload_too_large"));
let max = vec![1u8; attachments::MAX_BYTES];
let (status, _) = request(
&env,
"PUT",
&format!("/v1/attachments/{}", sha(&max)),
&headers,
&max,
);
assert_eq!(status, 201);
let r = get_raw(&env, "owner", None, &hash);
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(r.headers()["Content-Type"], "image/png");
assert_eq!(r.headers()["Content-Disposition"], "attachment");
assert_eq!(futures_body(r), bytes);
// Other users' spaces do not see the file.
assert_eq!(
get_raw(&env, "member", None, &hash).status(),
StatusCode::NOT_FOUND
);
assert_eq!(
get_raw(&env, "owner", None, "nothex").status(),
StatusCode::NOT_FOUND
);
}
#[test]
fn attachments_are_per_ledger_and_check_membership() {
let env = env();
let id = create_ledger(&env);
join_member(&env, &id);
let bytes = b"%PDF-1.7 receipt";
let hash = sha(bytes);
assert_eq!(
put(&env, "member", Some(&id), &hash, "application/pdf", bytes).0,
201
);
let r = get_raw(&env, "owner", Some(&id), &hash);
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(futures_body(r), bytes);
// The same file in the personal space is stored separately.
assert_eq!(
get_raw(&env, "owner", None, &hash).status(),
StatusCode::NOT_FOUND
);
assert_eq!(
put(&env, "owner", None, &hash, "application/pdf", bytes).0,
201
);
let (status, v) = put(&env, "stranger", Some(&id), &hash, "application/pdf", bytes);
assert_eq!((status, code_of(&v)), (403, "forbidden"));
assert_eq!(
get_raw(&env, "stranger", Some(&id), &hash).status(),
StatusCode::FORBIDDEN
);
assert_eq!(
get_raw(&env, "owner", Some("missing"), &hash).status(),
StatusCode::NOT_FOUND
);
// Ledger deletion removes its blobs, the personal copy stays.
let ledger_dir = env.dir.join("ledgers").join(&id);
assert!(ledger_dir.is_dir());
call(
&env,
"DELETE",
&format!("/v1/ledgers/{id}"),
Some("owner"),
None,
b"",
);
assert!(!ledger_dir.exists());
assert_eq!(get_raw(&env, "owner", None, &hash).status(), StatusCode::OK);
let n: i64 = env
.state
.db
.lock()
.unwrap()
.query_row("SELECT count(*) FROM attachment_blobs", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn blobs_are_collected_when_no_live_record_references_them() {
let env = env();
let bytes = b"jpeg bytes";
let hash = sha(bytes);
let pending = b"uploaded, not yet synced";
let pending_hash = sha(pending);
assert_eq!(put(&env, "owner", None, &hash, "image/jpeg", bytes).0, 201);
assert_eq!(
put(&env, "owner", None, &pending_hash, "image/jpeg", pending).0,
201
);
let file = env
.dir
.join("users")
.join("owner")
.join(&hash[..2])
.join(&hash);
assert!(file.is_file());
let (status, _) = sync_call(
&env,
"owner",
None,
json!({"changes":[attachment_change("a1", &hash, "2026-01-01T00:00:00Z", false),
attachment_change("a2", &hash, "2026-01-01T00:00:00Z", false)]}),
);
assert_eq!(status, 200);
// One of two referencing records is deleted: the blob stays.
sync_call(
&env,
"owner",
None,
json!({"changes":[attachment_change("a1", &hash, "2026-01-02T00:00:00Z", true)]}),
);
assert_eq!(get_raw(&env, "owner", None, &hash).status(), StatusCode::OK);
// The last one is deleted: the blob is collected.
sync_call(
&env,
"owner",
None,
json!({"changes":[attachment_change("a2", &hash, "2026-01-02T00:00:00Z", true)]}),
);
assert_eq!(
get_raw(&env, "owner", None, &hash).status(),
StatusCode::NOT_FOUND
);
assert!(!file.exists());
// A fresh upload whose record is not synced yet survives the grace period…
assert_eq!(
get_raw(&env, "owner", None, &pending_hash).status(),
StatusCode::OK
);
// …but is collected once it is older than the grace period.
env.state
.db
.lock()
.unwrap()
.execute(
"UPDATE attachment_blobs SET uploaded_at='2000-01-01T00:00:00.000000000Z'",
[],
)
.unwrap();
sync_call(
&env,
"owner",
None,
json!({"changes":[attachment_change("a3", &hash, "2026-01-03T00:00:00Z", true)]}),
);
assert_eq!(
get_raw(&env, "owner", None, &pending_hash).status(),
StatusCode::NOT_FOUND
);
// Re-uploading a collected blob creates it again.
assert_eq!(put(&env, "owner", None, &hash, "image/jpeg", bytes).0, 201);
}
#[tokio::test]
async fn large_uploads_pass_the_body_limit_only_when_authorized() {
let env = env();
let state = Arc::new(State::new(
env.state.cfg.clone(),
Mutex::new(crate::database::open(":memory:").unwrap()),
));
state
.db
.lock()
.unwrap()
.execute(
"INSERT INTO users VALUES('owner','owner@example.com','hash','now')",
[],
)
.unwrap();
let (token, _) = state
.tokens
.access_token("owner", "owner@example.com")
.unwrap();
let bytes = vec![7u8; (2 << 20) + 10];
let hash = sha(&bytes);
let r = handle(
ExtractState(state.clone()),
Request::builder()
.method("PUT")
.uri(format!("/v1/attachments/{hash}"))
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "image/heic")
.body(Body::from(bytes.clone()))
.unwrap(),
)
.await;
assert_eq!(r.status(), StatusCode::CREATED);
assert_eq!(r.headers()["Cache-Control"], "no-store");
let r = handle(
ExtractState(state.clone()),
Request::builder()
.method("PUT")
.uri(format!("/v1/attachments/{hash}"))
.header("Content-Type", "image/heic")
.body(Body::from(bytes))
.unwrap(),
)
.await;
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn cors_preflights_allow_v2_methods_and_ledger_header() {
let env = env();
let state = Arc::new(State::new(
env.state.cfg.clone(),
Mutex::new(crate::database::open(":memory:").unwrap()),
));
for (uri, request_method) in [("/v1/ledgers/x", None), ("/v1/sync", Some("POST"))] {
let mut builder = Request::builder()
.method("OPTIONS")
.uri(uri)
.header("Origin", "https://allowed.example");
if let Some(m) = request_method {
builder = builder
.header("Access-Control-Request-Method", m)
.header("Access-Control-Request-Headers", "x-balance-ledger");
}
let r = handle(
ExtractState(state.clone()),
builder.body(Body::empty()).unwrap(),
)
.await;
assert_eq!(r.status(), StatusCode::NO_CONTENT);
assert_eq!(
r.headers()["Access-Control-Allow-Headers"],
"Authorization, Content-Type, X-Balance-Ledger"
);
assert_eq!(
r.headers()["Access-Control-Allow-Methods"],
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
);
}
}
+80 -11
View File
@@ -259,11 +259,68 @@ fn failure(status: u16, code: &'static str, message: &str) -> SyncError {
(status, code, message.to_owned()) (status, code, message.to_owned())
} }
/// Entities accepted by `/v1/sync` (contract v2, section 1).
pub const ENTITIES: [&str; 11] = [
"transaction",
"category",
"budget",
"account",
"transfer",
"recurring",
"goal",
"debt",
"rate",
"setting",
"attachment",
];
/// A data space: the user's personal records (v1 tables) or a shared ledger.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Space {
User(String),
Ledger(String),
}
impl Space {
/// Records table, changes table and the column that selects the space.
pub fn tables(&self) -> (&'static str, &'static str, &'static str) {
match self {
Space::User(_) => ("records", "changes", "user_id"),
Space::Ledger(_) => ("ledger_records", "ledger_changes", "ledger_id"),
}
}
pub fn id(&self) -> &str {
match self {
Space::User(id) | Space::Ledger(id) => id,
}
}
/// Stable key of the space in `attachment_blobs`.
pub fn key(&self) -> String {
match self {
Space::User(id) => format!("user:{id}"),
Space::Ledger(id) => format!("ledger:{id}"),
}
}
}
/// Synchronize the personal space of `user_id`.
#[cfg(test)]
pub fn synchronize_wire( pub fn synchronize_wire(
db: &mut Connection, db: &mut Connection,
user_id: &str, user_id: &str,
body: &[u8], body: &[u8],
) -> Result<Vec<u8>, SyncError> { ) -> Result<Vec<u8>, SyncError> {
synchronize_space(db, &Space::User(user_id.to_owned()), body).map(|(response, _)| response)
}
/// Apply a sync request in `space`. Besides the response body, it returns the
/// attachment blobs that became garbage; their rows are already deleted in
/// the committed transaction and the caller removes the files.
pub fn synchronize_space(
db: &mut Connection,
space: &Space,
body: &[u8],
) -> Result<(Vec<u8>, Vec<String>), SyncError> {
let invalid_json = || failure(400, "invalid_json", "Request body is not valid JSON"); let invalid_json = || failure(400, "invalid_json", "Request body is not valid JSON");
let body = &body[..body.len().min(2 << 20)]; let body = &body[..body.len().min(2 << 20)];
// The original decoder reads the first JSON value, without requiring EOF. // The original decoder reads the first JSON value, without requiring EOF.
@@ -287,7 +344,9 @@ pub fn synchronize_wire(
)); ));
} }
for c in &request.changes { for c in &request.changes {
let message = if !matches!(c.entity.as_str(), "transaction" | "category" | "budget") { let message = if !ENTITIES.contains(&c.entity.as_str()) {
// The message is part of the recorded v1 reference responses
// (tools/compatibility_expected.txt), so it is kept unchanged.
Some("entity must be transaction, category, or budget") Some("entity must be transaction, category, or budget")
} else if c.id.is_empty() || c.id.len() > 128 { } else if c.id.is_empty() || c.id.len() > 128 {
Some("record id is invalid") Some("record id is invalid")
@@ -313,12 +372,18 @@ pub fn synchronize_wire(
let tx = db let tx = db
.transaction() .transaction()
.map_err(|_| failure(500, "sync_failed", "Could not begin synchronization"))?; .map_err(|_| failure(500, "sync_failed", "Could not begin synchronization"))?;
let touches_attachments = request.changes.iter().any(|c| c.entity == "attachment");
for c in request.changes { for c in request.changes {
apply(&tx, user_id, c) apply(&tx, space, c).map_err(|_| failure(500, "sync_failed", "Could not apply changes"))?;
.map_err(|_| failure(500, "sync_failed", "Could not apply changes"))?;
} }
let garbage = if touches_attachments {
crate::attachments::collect_garbage(&tx, space, Utc::now())
.map_err(|_| failure(500, "sync_failed", "Could not apply changes"))?
} else {
Vec::new()
};
let (changes, cursor, more) = if request.pull.unwrap_or(true) { let (changes, cursor, more) = if request.pull.unwrap_or(true) {
load(&tx, user_id, request.cursor) load(&tx, space, request.cursor)
.map_err(|_| failure(500, "sync_failed", "Could not load changes"))? .map_err(|_| failure(500, "sync_failed", "Could not load changes"))?
} else { } else {
(Vec::new(), request.cursor, false) (Vec::new(), request.cursor, false)
@@ -339,7 +404,7 @@ pub fn synchronize_wire(
) )
.as_bytes(), .as_bytes(),
); );
Ok(response) Ok((response, garbage))
} }
#[cfg(test)] #[cfg(test)]
@@ -351,10 +416,12 @@ fn synchronize(db: &mut Connection, user_id: &str, body: &[u8]) -> Result<Value,
type InternalError = Box<dyn std::error::Error + Send + Sync>; type InternalError = Box<dyn std::error::Error + Send + Sync>;
fn apply(tx: &Transaction<'_>, user: &str, c: Change) -> Result<(), InternalError> { fn apply(tx: &Transaction<'_>, space: &Space, c: Change) -> Result<(), InternalError> {
let (records, changes, key) = space.tables();
let user = space.id();
let current: Option<(String, i64)> = tx let current: Option<(String, i64)> = tx
.query_row( .query_row(
"SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?", &format!("SELECT updated_at,version FROM {records} WHERE {key}=? AND entity=? AND record_id=?"),
params![user, c.entity, c.id], params![user, c.entity, c.id],
|r| Ok((r.get(0)?, r.get(1)?)), |r| Ok((r.get(0)?, r.get(1)?)),
) )
@@ -373,17 +440,19 @@ fn apply(tx: &Transaction<'_>, user: &str, c: Change) -> Result<(), InternalErro
c.payload.as_deref() c.payload.as_deref()
}; };
let time = timestamp(updated); let time = timestamp(updated);
tx.execute("INSERT INTO records(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,CAST(? AS TEXT),?,?,?) ON CONFLICT(user_id,entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version", params![user,c.entity,c.id,payload,time,c.deleted,version])?; tx.execute(&format!("INSERT INTO {records}({key},entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,CAST(? AS TEXT),?,?,?) ON CONFLICT({key},entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version"), params![user,c.entity,c.id,payload,time,c.deleted,version])?;
tx.execute("INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,CAST(? AS TEXT),?,?,?)", params![user,c.entity,c.id,payload,time,c.deleted,version])?; tx.execute(&format!("INSERT INTO {changes}({key},entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,CAST(? AS TEXT),?,?,?)"), params![user,c.entity,c.id,payload,time,c.deleted,version])?;
Ok(()) Ok(())
} }
fn load( fn load(
tx: &Transaction<'_>, tx: &Transaction<'_>,
user: &str, space: &Space,
after: i64, after: i64,
) -> Result<(Vec<Vec<u8>>, i64, bool), InternalError> { ) -> Result<(Vec<Vec<u8>>, i64, bool), InternalError> {
let mut stmt = tx.prepare("SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM changes WHERE user_id=? AND sequence>? ORDER BY sequence ASC LIMIT ?")?; let (_, changes, key) = space.tables();
let user = space.id();
let mut stmt = tx.prepare(&format!("SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM {changes} WHERE {key}=? AND sequence>? ORDER BY sequence ASC LIMIT ?"))?;
let mut rows = stmt.query(params![user, after, 501])?; let mut rows = stmt.query(params![user, after, 501])?;
let mut result = Vec::new(); let mut result = Vec::new();
let mut cursor = after; let mut cursor = after;
@@ -1,43 +1,5 @@
package database -- SQLite schema created by the original Go server (v1.0.0, internal/database/database.go).
-- Rust must keep opening databases with exactly this schema; do not edit.
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
func Open(path string) (*sql.DB, error) {
directory := filepath.Dir(path)
if directory != "." {
if err := os.MkdirAll(directory, 0o750); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
}
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
if err := migrate(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
func migrate(ctx context.Context, db *sql.DB) error {
const schema = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE, email TEXT NOT NULL UNIQUE COLLATE NOCASE,
@@ -74,7 +36,3 @@ CREATE TABLE IF NOT EXISTS changes (
version INTEGER NOT NULL version INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence); CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);
`
_, err := db.ExecContext(ctx, schema)
return err
}
+51 -28
View File
@@ -1,6 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Black-box Go/Rust compatibility and bilateral SQLite takeover checks (stdlib only).""" """Black-box HTTP/JSON regression checks against the recorded Go reference (stdlib only).
compatibility_expected.txt holds the observable responses of the original Go
server (v1.0.0), which the Apple and Android clients were built against. Any
difference is a client-visible API change. After an intentional change, review
the diff and re-record with --update.
"""
import argparse import argparse
import ast
import base64 import base64
import concurrent.futures import concurrent.futures
import contextlib import contextlib
@@ -9,6 +16,7 @@ import hashlib
import hmac import hmac
import json import json
import os import os
import pprint
from pathlib import Path from pathlib import Path
import socket import socket
import sqlite3 import sqlite3
@@ -17,6 +25,7 @@ import tempfile
import time import time
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
EXPECTED = Path(__file__).with_name('compatibility_expected.txt')
SECRET = 'compatibility-test-secret-not-for-deployment-0123456789' SECRET = 'compatibility-test-secret-not-for-deployment-0123456789'
PASSWORD = 'compatibility password' PASSWORD = 'compatibility password'
@@ -370,38 +379,52 @@ def raw_json_scenarios(binary, database):
return observed return observed
def web_assets(value):
"""Stand in for embedded web files, so the reference survives web client edits."""
if isinstance(value, bytes):
for asset in sorted((ROOT / 'web').iterdir()):
if value == asset.read_bytes():
return ('web asset', asset.name)
return value
if isinstance(value, dict):
return {k: web_assets(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return type(value)(web_assets(v) for v in value)
return value
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--go-binary', type=Path) parser.add_argument('--binary', type=Path, default=ROOT / 'target/debug/balance-server')
parser.add_argument('--rust-binary', type=Path, default=ROOT / 'target/debug/balance-server') parser.add_argument('--update', action='store_true',
help=f'record the responses as the new reference in {EXPECTED.name}')
args = parser.parse_args() args = parser.parse_args()
binary = args.binary.resolve()
assert binary.is_file(), f'Build first: cargo build --locked (missing {binary})'
with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory: with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory:
directory = Path(directory) directory = Path(directory)
go = args.go_binary.resolve() if args.go_binary else directory / 'go-server' actual = {}
if args.go_binary is None: print('Running HTTP/JSON scenarios...', flush=True)
subprocess.run(['go','build','-o',str(go),'./cmd/balance-server'], cwd=ROOT, check=True) actual['scenario'] = scenario(binary, directory / 'scenario.db')
rust = args.rust_binary.resolve() print('Running HTTP/JSON edge cases...', flush=True)
assert rust.is_file(), f'Build Rust first: cargo build --locked (missing {rust})' actual['edge'] = edge_scenarios(binary, directory / 'edge.db')
print('Running Go reference scenarios...', flush=True) print('Running raw JSON payload and Unicode preservation...', flush=True)
expected = scenario(go, directory / 'go.db') actual['raw'] = raw_json_scenarios(binary, directory / 'raw.db')
print('Running Rust scenarios...', flush=True) actual = web_assets(actual)
actual = scenario(rust, directory / 'rust.db') if args.update:
for key in expected: EXPECTED.write_text(pprint.pformat(actual, width=100) + '\n')
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' print(f'Recorded {EXPECTED.relative_to(ROOT)}')
print('Comparing HTTP/JSON edge cases...', flush=True) else:
expected = edge_scenarios(go, directory / 'go-edge.db') expected = ast.literal_eval(EXPECTED.read_text())
actual = edge_scenarios(rust, directory / 'rust-edge.db') for group in expected:
for key in expected: for key in expected[group]:
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' assert actual[group].get(key) == expected[group][key], (
print('Comparing raw JSON payload and Unicode preservation...', flush=True) f'Reference mismatch: {group} {key}\n'
expected = raw_json_scenarios(go, directory / 'go-raw.db') f'Expected: {expected[group][key]!r}\nActual: {actual[group].get(key)!r}')
actual = raw_json_scenarios(rust, directory / 'rust-raw.db') assert actual[group].keys() == expected[group].keys(), f'Scenario set changed in {group}; re-record with --update'
for key in expected: print('Checking SQLite persistence across restarts...', flush=True)
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' takeover(binary, binary, directory / 'restart.db')
for first, second, name in [(go,rust,'go-rust'),(rust,go,'rust-go')]: print('PASS: HTTP/JSON reference scenarios and SQLite persistence')
print(f'Checking {name} SQLite takeover and rollback...', flush=True)
takeover(first, second, directory / f'{name}.db')
print('PASS: HTTP differential scenarios and bilateral SQLite takeover/rollback')
if __name__ == '__main__': if __name__ == '__main__':
+769
View File
@@ -0,0 +1,769 @@
{'edge': {'expired refresh': (401,
{},
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}}),
('DELETE', '/app.js'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/health'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/me'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('HEAD', '/app.js'): (200, {'content-type': 'text/javascript; charset=utf-8'}, b''),
('HEAD', '/health'): (200, {'content-type': 'application/json; charset=utf-8'}, b''),
('HEAD', '/v1/auth/login'): (404,
{'content-type': 'application/json; charset=utf-8'},
b''),
('HEAD', '/v1/me'): (401, {'content-type': 'application/json; charset=utf-8'}, b''),
('HEAD', '/v1/sync'): (404, {'content-type': 'application/json; charset=utf-8'}, b''),
('PATCH', '/app.js'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/health'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/app.js'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/health'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/v1/auth/login'): (400,
{'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('POST', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/v1/sync'): (401,
{'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('PUT', '/app.js'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/health'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('bearer', ''): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('bearer', 'Basic bad'): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is '
'required'}}),
('bearer', 'Bearer '): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('bearer', 'Bearer bad'): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('bearer', 'bearer bad'): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is '
'required'}}),
('login', 0): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 1): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 2): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 3): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('login', 5): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 6): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('logout JSON', 0): (204, {}, b''),
('logout JSON', 1): (204, {}, b''),
('logout JSON', 2): (204, {}, b''),
('logout JSON', 3): (204, {}, b''),
('logout JSON', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('logout JSON', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('password boundary', 0): 201,
('password boundary', 1): 201,
('password boundary', 2): (400,
{},
{'error': {'code': 'invalid_password',
'message': 'password is too long'}}),
('path', '/%2e%2e/app.js'): (200,
{'cache-control': 'public, max-age=3600',
'content-type': 'text/javascript; charset=utf-8'},
('web asset', 'app.js')),
('path', '/%61pp.js'): (200,
{'cache-control': 'public, max-age=3600',
'content-type': 'text/javascript; charset=utf-8'},
('web asset', 'app.js')),
('path', '//app.js'): (307,
{'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/a/../app.js'): (307,
{'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/app.js/'): (301,
{'cache-control': 'public, max-age=3600', 'location': '../app.js'},
b''),
('path', '/index.html'): (301, {'cache-control': 'no-cache', 'location': './'}, b''),
('path', '/v1'): (404,
{'content-type': 'text/plain; charset=utf-8'},
b'404 page not found\n'),
('path', '/v1/'): (404,
{'cache-control': 'no-store',
'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'not_found', 'message': 'Endpoint not found'}}),
('path', '/v1/../app.js'): (307,
{'cache-control': 'no-store',
'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/v1//me'): (307,
{'cache-control': 'no-store',
'content-type': 'text/html; charset=utf-8',
'location': '/v1/me'},
b'<a href="/v1/me">Temporary Redirect</a>.\n\n'),
('range', 'bytes=-10'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 1112-1121/1122',
'content-type': 'image/svg+xml'},
b'/>\n</svg>\n'),
('range', 'bytes=0-0,2-2'): (200,
{'accept-ranges': 'bytes', 'content-type': 'image/svg+xml'},
('web asset', 'icon.svg')),
('range', 'bytes=0-9'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 0-9/1122',
'content-type': 'image/svg+xml'},
b'<svg xmlns'),
('range', 'bytes=10-'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 10-1121/1122',
'content-type': 'image/svg+xml'},
b'="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="im'
b'g" aria-labelledby="title">\n <title id="title">Balance</tit'
b'le>\n <defs>\n <linearGradient id="bg" x1="70" y1="36" x2='
b'"446" y2="476" gradientUnits="userSpaceOnUse">\n <stop s'
b'top-color="#7777ef"/>\n <stop offset="1" stop-color="#42'
b'42b8"/>\n </linearGradient>\n <linearGradient id="shine"'
b' x1="144" y1="122" x2="366" y2="386" gradientUnits="userSpac'
b'eOnUse">\n <stop stop-color="#fff"/>\n <stop offset='
b'"1" stop-color="#dedfff"/>\n </linearGradient>\n </def'
b's>\n <rect x="28" y="28" width="456" height="456" rx="128" f'
b'ill="url(#bg)"/>\n <path d="M141 173c0-25 20-45 45-45h140c25'
b' 0 45 20 45 45v18H186c-25 0-45 20-45 45v-63Z" fill="#fff" op'
b'acity=".5"/>\n <path d="M116 222c0-28 23-51 51-51h179c28 0 5'
b'1 23 51 51v126c0 28-23 51-51 51H167c-28 0-51-23-51-51V222Z" '
b'fill="url(#shine)"/>\n <path d="M302 258h95v86h-95c-24 0-43-'
b'19-43-43s19-43 43-43Z" fill="#5b5bd6"/>\n <circle cx="306" c'
b'y="301" r="13" fill="#fff"/>\n <path d="M184 250h44M184 289h'
b'44M184 328h44" stroke="#5b5bd6" stroke-width="18" stroke-lin'
b'ecap="round" opacity=".72"/>\n</svg>\n'),
('range', 'bytes=10-1'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('range', 'bytes=99999999-'): (416,
{'content-range': 'bytes */1122',
'content-type': 'text/plain; charset=utf-8'},
b'invalid range: failed to overlap\n'),
('range', 'bytes=bad'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('range', 'items=0-1'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('raw sync', 0): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 1): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 2): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 3): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 6): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'entity must be transaction, category, or '
'budget'}}),
('raw sync', 7): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 8): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 9): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 10): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('refresh JSON', 0): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 1): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 2): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 3): (401,
{},
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}}),
('refresh JSON', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('refresh JSON', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('register', 0): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 1): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 2): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 3): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('register', 5): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 6): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('signed claims', 0): (200, {}, {'email': 'e', 'id': 'u'}),
('signed claims', 1): (200, {}, {'email': 'e', 'id': 'u'}),
('signed claims', 2): (200, {}, {'email': '', 'id': 'u'}),
('signed claims', 3): (200, {}, {'email': '', 'id': 'u'}),
('signed claims', 4): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('signed claims', 5): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('signed claims', 6): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('sync validation', 0): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'entity must be transaction, category, or '
'budget'}}),
('sync validation', 1): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'record id is invalid'}}),
('sync validation', 2): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 3): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 4): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'updatedAt is required'}}),
('sync validation', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('sync validation', 6): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 7): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('sync validation', 8): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 9): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 10): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 11): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 12): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'updatedAt is too far in the future'}}),
('sync validation', 13): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'payload is too large'}})},
'raw': {'raw database payloads': [(b'wire-0',
b'{ "duplicate":1, "duplicate":2, "n":123456789012345678901234'
b'567890, "big":1e999 }'),
(b'wire-1',
b'{"lone":"\\ud800","low":"\\udfff","pair":"\\ud83d\\ude00"}'),
(b'wire-2', b'{"invalid":"\xff\xc0\xed\xa0\x80"}'),
(b'wire-3',
b'{"html":"<>&","separator":"\xe2\x80\xa8\xe2\x80\xa9"}'),
(b'wire-4',
b'{"nested":[{"payload":{"id":"inner"}},"quote\\"end",null,true'
b']}'),
(b'\xef\xbf\xbd', b'{}')],
('unicode credentials', 0): (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('unicode credentials', 1): (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('unicode id', 0): {'changes': [{'deleted': False,
'entity': 'category',
'id': '�',
'payload': {},
'sequence': 6,
'updatedAt': '2025-01-02T00:00:00Z',
'version': 1}],
'cursor': 6,
'hasMore': False},
('unicode id', 1): {'changes': [], 'cursor': 6, 'hasMore': False},
('wire payload', 0): b'{"duplicate":1,"duplicate":2,"n":123456789012345678901234567890,'
b'"big":1e999}',
('wire payload', 1): b'{"lone":"\\ud800","low":"\\udfff","pair":"\\ud83d\\ude00"}',
('wire payload', 2): b'{"invalid":"\xff\xc0\xed\xa0\x80"}',
('wire payload', 3): b'{"html":"\\u003c\\u003e\\u0026","separator":"\\u2028\\u2029"}',
('wire payload', 4): b'{"nested":[{"payload":{"id":"inner"}},"quote\\"end",null,true]}'},
'scenario': {'/': (200,
{'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; base-uri 'none'; connect-src "
"'self'; font-src 'self'; form-action 'self'; "
"frame-ancestors 'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; style-src "
"'self'; worker-src 'self'",
'content-type': 'text/html; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'index.html')),
'/app.js': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors 'none'; "
"img-src 'self' data:; manifest-src 'self'; "
"script-src 'self'; style-src 'self'; "
"worker-src 'self'",
'content-type': 'text/javascript; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'app.js')),
'/icon.svg': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; "
"style-src 'self'; worker-src 'self'",
'content-type': 'image/svg+xml',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'icon.svg')),
'/manifest.webmanifest': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri "
"'none'; connect-src 'self'; "
"font-src 'self'; form-action "
"'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; "
"script-src 'self'; style-src "
"'self'; worker-src 'self'",
'content-type': 'text/plain; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), '
'geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'manifest.webmanifest')),
'/missing-file': (404,
{'cache-control': None,
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src "
"'self'; style-src 'self'; worker-src "
"'self'",
'content-type': 'text/plain; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
b'404 page not found\n'),
'/service-worker.js': (200,
{'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; base-uri "
"'none'; connect-src 'self'; "
"font-src 'self'; form-action "
"'self'; frame-ancestors 'none'; "
"img-src 'self' data:; "
"manifest-src 'self'; script-src "
"'self'; style-src 'self'; "
"worker-src 'self'",
'content-type': 'text/javascript; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), '
'geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'service-worker.js')),
'/styles.css': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; "
"style-src 'self'; worker-src 'self'",
'content-type': 'text/css; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'styles.css')),
'GET /v1/me None': (401,
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
'GET /v1/missing None': (404,
{'error': {'code': 'not_found',
'message': 'Endpoint not found'}}),
"POST /v1/auth/login {'email': 'User@Example.COM', 'password': 'compatibility password'}": (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email '
'or '
'password '
'is '
'incorrect'}}),
"POST /v1/auth/login {'unknown': 1}": (400,
{'error': {'code': 'invalid_json',
'message': 'Request body is not '
'valid JSON'}}),
"POST /v1/auth/refresh {'refreshToken': 'bad'}": (401,
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh '
'token is '
'invalid or '
'expired'}}),
'POST /v1/auth/refresh {}': (400,
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
"POST /v1/auth/register {'email': 'a@b', 'password': 'short'}": (400,
{'error': {'code': 'invalid_password',
'message': 'password '
'must '
'contain '
'at '
'least '
'8 '
'characters'}}),
"POST /v1/auth/register {'email': 'bad', 'password': 'compatibility password'}": (400,
{'error': {'code': 'invalid_email',
'message': 'Enter '
'a '
'valid '
'email '
'address'}}),
'deleted': {'changes': [{'deleted': True,
'entity': 'transaction',
'id': 'one',
'sequence': 5,
'updatedAt': '2025-01-02T00:00:00Z',
'version': 2}],
'cursor': 5,
'hasMore': False},
'duplicate': {'error': {'code': 'email_exists',
'message': 'An account with this email already exists'}},
'expired access': {'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}},
'first': {'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'one',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'category',
'id': 'cat',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 3,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'budget',
'id': 'budget',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 4,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 4,
'hasMore': False},
'https://allowed.example': (204,
{'access-control-allow-headers': 'Authorization, '
'Content-Type',
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-origin': 'https://allowed.example',
'vary': 'Origin'}),
'https://denied.example': (204, {}),
'idempotent': {'changes': [], 'cursor': 4, 'hasMore': False},
'invalid bearer': {'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}},
"invalid sync {'changes': [{'entity': 'category', 'id': 'x'}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'updatedAt '
'is '
'required'}}),
"invalid sync {'changes': [{'entity': 'transaction', 'id': '', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': {'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'record '
'id '
'is '
'invalid'}}),
"invalid sync {'changes': [{'entity': 'transaction', 'id': 'x', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': None}]}": (200,
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
"invalid sync {'changes': [{'entity': 'unknown', 'id': 'x', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': {'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'entity '
'must '
'be '
'transaction, '
'category, '
'or '
'budget'}}),
"invalid sync {'cursor': -1}": (400,
{'error': {'code': 'invalid_sync_request',
'message': 'Cursor or change count is '
'invalid'}}),
"invalid sync {'extra': 1}": (400,
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid '
'JSON'}}),
'logout refresh': {'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}},
'malformed': {'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}},
'pagination': (505, 510),
'rate limit': {'error': {'code': 'rate_limited',
'message': 'Too many authentication requests'}},
'refresh replay': {'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}},
'registration disabled': {'error': {'code': 'registration_disabled',
'message': 'Registration is disabled'}}}}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.