From 10272f3851e5ced4d87b60fb69ce5889bb125f54 Mon Sep 17 00:00:00 2001 From: wt Date: Fri, 30 Oct 2026 16:27:33 +0700 Subject: [PATCH] Remove the Go reference server; keep only Rust 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 --- .github/workflows/ci.yml | 52 +- AGENTS.md | 37 +- Dockerfile | 2 +- README.md | 26 +- cmd/balance-server/main.go | 55 -- docs/ARCHITECTURE.md | 11 +- docs/DATA_MODEL.md | 25 +- docs/DEPLOYMENT.md | 22 +- docs/DEVELOPMENT.md | 17 +- docs/avatar.svg | 2 +- docs/cover-square.svg | 2 +- docs/cover.svg | 2 +- go.mod | 21 - go.sum | 51 -- internal/auth/password.go | 61 -- internal/auth/tokens.go | 90 -- internal/config/config.go | 93 --- internal/httpapi/auth_handlers.go | 245 ------ internal/httpapi/server.go | 185 ----- internal/httpapi/server_test.go | 204 ----- internal/httpapi/sync_handler.go | 169 ---- internal/httpapi/web.go | 34 - src/database.rs | 13 +- src/server.rs | 15 +- .../fixtures/go_schema_v1.sql | 46 +- tools/compatibility.py | 79 +- tools/compatibility_expected.txt | 769 ++++++++++++++++++ {internal/httpapi/web => web}/app.js | 0 {internal/httpapi/web => web}/icon.svg | 0 {internal/httpapi/web => web}/index.html | 0 .../httpapi/web => web}/manifest.webmanifest | 0 .../httpapi/web => web}/service-worker.js | 0 {internal/httpapi/web => web}/styles.css | 0 33 files changed, 890 insertions(+), 1438 deletions(-) delete mode 100644 cmd/balance-server/main.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/auth/password.go delete mode 100644 internal/auth/tokens.go delete mode 100644 internal/config/config.go delete mode 100644 internal/httpapi/auth_handlers.go delete mode 100644 internal/httpapi/server.go delete mode 100644 internal/httpapi/server_test.go delete mode 100644 internal/httpapi/sync_handler.go delete mode 100644 internal/httpapi/web.go rename internal/database/database.go => tests/fixtures/go_schema_v1.sql (56%) create mode 100644 tools/compatibility_expected.txt rename {internal/httpapi/web => web}/app.js (100%) rename {internal/httpapi/web => web}/icon.svg (100%) rename {internal/httpapi/web => web}/index.html (100%) rename {internal/httpapi/web => web}/manifest.webmanifest (100%) rename {internal/httpapi/web => web}/service-worker.js (100%) rename {internal/httpapi/web => web}/styles.css (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f7988f..e5694b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,10 +9,6 @@ env: CARGO_TERM_COLOR: always # Pin the toolchain to the declared MSRV so CI matches production. 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: rust: @@ -58,39 +54,10 @@ jobs: path: target/release/balance-server 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: - name: Go ↔ Rust differential + SQLite takeover + name: HTTP/JSON reference + SQLite persistence runs-on: ubuntu-latest - needs: [rust, go] + needs: [rust] steps: - name: Checkout uses: actions/checkout@v4 @@ -100,11 +67,6 @@ jobs: with: toolchain: ${{ env.RUST_TOOLCHAIN }} - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - name: Install Python uses: actions/setup-python@v5 with: @@ -121,10 +83,8 @@ jobs: restore-keys: | ${{ runner.os }}-cargo- - - name: Build Rust (debug) and Go reference - run: | - cargo build --locked - go build -o target/integration/go-server ./cmd/balance-server + - name: Build Rust (debug) + run: cargo build --locked - - name: Run differential compatibility scenarios - run: python3 tools/compatibility.py --rust-binary target/debug/balance-server + - name: Compare against the recorded Go reference + run: python3 tools/compatibility.py --binary target/debug/balance-server diff --git a/AGENTS.md b/AGENTS.md index 10f627d..17def6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,28 +8,14 @@ ## Быстрая карта проекта -Production-сервер написан на Rust 1.90+. Go 1.25.0+ сохранён как -эталон совместимости API/SQLite и вариант отката. +Сервер написан на Rust 1.90+. Исходная Go-реализация удалена после v1.0.0 +(её исходники остались в теге `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`, `database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`. -Веб-клиент из `internal/httpapi/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**. +Веб-клиент из `web/` встроен в Rust executable. Обнаруженные технологии/интеграции: SQLite, Docker. @@ -106,12 +92,13 @@ cargo fmt --check cargo test --locked cargo clippy --locked --all-targets -- -D warnings cargo build --locked --release -python3 tools/compatibility.py --rust-binary target/release/balance-server -go test ./... -go vet ./... -go build ./... +python3 tools/compatibility.py --binary target/release/balance-server ``` +Если `tools/compatibility.py` нашёл расхождение, это изменение видно клиентам. +Перезаписывай эталон (`--update`) только для намеренного изменения API и +после обновления документации. + Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI. ### 8. Документация @@ -130,8 +117,6 @@ go build ./... - [ ] Sync остаётся идемпотентным. - [ ] DB migration учтена. - [ ] Concurrent requests безопасны. -- [ ] `go test ./...` проходит. - [ ] Rust tests, fmt, clippy и release build проходят. -- [ ] Differential HTTP/JSON и Go ↔ Rust SQLite takeover проходят. -- [ ] `go vet ./...` проходит. +- [ ] `tools/compatibility.py` совпадает с записанным эталоном Go. - [ ] Документация обновлена. diff --git a/Dockerfile b/Dockerfile index 707f217..2f13cd0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ RUN set -eu; \ WORKDIR /src COPY Cargo.toml Cargo.lock ./ COPY src ./src -COPY internal/httpapi/web ./internal/httpapi/web +COPY web ./web RUN . /cross.env \ && cargo build --locked --release --target "$RUST_TARGET" \ && cp "target/$RUST_TARGET/release/balance-server" /balance-server diff --git a/README.md b/README.md index 9dc04b8..934b922 100644 --- a/README.md +++ b/README.md @@ -45,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 bundled SQLite. `cargo build --locked --release --target ` requires the -matching linker and C compiler. The retained Go implementation is a compatibility -reference and rollback option: `go build -o balance-server-go ./cmd/balance-server`. +matching linker and C compiler. 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. @@ -114,9 +113,10 @@ The sync endpoint accepts transactions, custom categories and monthly budgets. T ## Compatibility and migration -The Rust server retains the HTTP/JSON API, embedded web client, environment -variables, SQLite schema, Argon2id password hashes, JWT format, refresh sessions -and sync cursor semantics of the Go implementation. Docker Compose still maps +The Rust server replaced the original Go implementation and retains its HTTP/JSON +API, embedded web client, environment variables, SQLite schema, Argon2id password +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`. Direct binary execution defaults to port 8080. @@ -126,8 +126,7 @@ against the same `BALANCE_DB_PATH`; do not run both writers simultaneously. Existing accounts, access/refresh tokens and saved device cursors remain usable. 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 -differential check), Rust and Python 3: +Verify the server locally with Rust and Python 3: ```sh cargo test --locked @@ -135,12 +134,11 @@ cargo fmt --check cargo clippy --locked --all-targets -- -D warnings cargo build --locked python3 tools/compatibility.py -go test ./... -go vet ./... -go build ./... ``` -The Python standard-library suite builds the Go reference, starts both binaries -on temporary loopback ports and databases, compares API responses and assets, -and tests SQLite takeover and rollback in both directions. To use release or -prebuilt executables, pass `--rust-binary PATH` and optionally `--go-binary PATH`. +The Python standard-library suite starts the server on temporary loopback ports +and databases, compares its API responses and assets with the responses recorded +from the original Go server (`tools/compatibility_expected.txt`), and checks that +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`. diff --git a/cmd/balance-server/main.go b/cmd/balance-server/main.go deleted file mode 100644 index e1d6f7f..0000000 --- a/cmd/balance-server/main.go +++ /dev/null @@ -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) - } -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ef89480..771ea95 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`. 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/` встроены при компиляции. -Пути сохранённой Go-реализации: - -```text -- internal/auth -- internal/config -- internal/database -- internal/httpapi -``` +Исходная Go-реализация удалена после v1.0.0 (исходники остались в теге `v1.0.0`). Её наблюдаемое поведение зафиксировано в `tools/compatibility_expected.txt`, а SQLite-схема — в `tests/fixtures/go_schema_v1.sql`; Rust сверяется с обоими в CI. Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 507ebee..8a0e604 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -2,27 +2,12 @@ Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ. -## Найденные определения моделей/schema +## Где определена schema -- internal/database/database.go -- internal/database/database.go -- internal/database/database.go -- internal/config/config.go -- 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 +- `src/database.rs` — создание и открытие схемы SQLite; +- `tests/fixtures/go_schema_v1.sql` — исходная схема Go-сервера v1.0.0; Rust обязан открывать такие базы без изменений; +- `src/sync.rs` — валидация и сериализация синхронизируемых записей; +- `src/auth.rs` — пользователи, пароли и refresh-сессии. ## Общие сущности diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 17053cc..6e40d5a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -3,9 +3,9 @@ ## Runtime and configuration The production executable is the Rust server, built with `cargo build --locked ---release`. SQLite is bundled into the executable. The Go implementation remains -in `cmd/balance-server` and `internal/` as a compatibility reference and rollback -option; build it with `go build -o balance-server-go ./cmd/balance-server`. +--release`. SQLite is bundled into the executable. The original Go implementation +was removed after v1.0.0; the `v1.0.0` tag still contains its sources for the +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 runtime, running as the unprivileged `balance` user. The image embeds the existing @@ -65,8 +65,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 database directory and secret. Rust-written accounts, password hashes, refresh sessions, records, tombstones and cursors remain readable by Go. Check health, -login, refresh and sync again. The compatibility suite exercises both directions, -including a second takeover back to the original implementation. +login, refresh and sync again. Before the Go sources were removed, the +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 restoring the complete backup directory. Restoring an older backup discards @@ -77,12 +78,11 @@ an active database or delete a Compose volume as part of an upgrade. ## Validation and operations Before release run `cargo test --locked`, `cargo build --locked --release`, -`python3 tools/compatibility.py --rust-binary target/release/balance-server`, and -the retained reference checks `go test ./...`, `go vet ./...`, `go build ./...`. -The differential suite uses temporary databases and loopback listeners, compares -API/errors/security headers/assets, tests browser cookies, pagination, LWW, -tombstones, concurrent pushes, token expiration, refresh rotation, rate limiting, -and shared-database takeover/rollback in both directions. +and `python3 tools/compatibility.py --binary target/release/balance-server`. +The suite uses temporary databases and loopback listeners, compares +API/errors/security headers/assets with the recorded Go reference, tests browser +cookies, pagination, LWW, tombstones, concurrent pushes, token expiration, refresh +rotation, rate limiting, and SQLite persistence across restarts. Keep regular encrypted backups with retention and restore drills. Monitor HTTP 5xx, sync errors, authentication failures, latency, database/change-log growth diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1c2923f..be16816 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 rustc --version cargo --version -go version +python3 --version ``` ## Установка зависимостей ```bash cargo fetch --locked -go mod download ``` ## Сборка ```bash cargo build --locked -go build ./... ``` ## Тесты @@ -32,13 +30,15 @@ go build ./... cargo test --locked cargo build --locked python3 tools/compatibility.py -go test ./... ``` -С race detector: +`tools/compatibility.py` запускает сервер на временных портах и базах и сравнивает +ответы API и статику с ответами исходного Go-сервера, записанными в +`tools/compatibility_expected.txt`. Расхождение означает изменение, видимое +клиентам. Если изменение намеренное, проверьте diff и перезапишите эталон: ```bash -go test -race ./... +python3 tools/compatibility.py --update ``` ## Static checks @@ -46,7 +46,6 @@ go test -race ./... ```bash cargo fmt --check cargo clippy --locked --all-targets -- -D warnings -go vet ./... ``` Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline. @@ -89,7 +88,7 @@ cargo clippy --locked --all-targets -- -D warnings ↓ 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 ↓ diff --git a/docs/avatar.svg b/docs/avatar.svg index 8c6d559..ca22ff1 100644 --- a/docs/avatar.svg +++ b/docs/avatar.svg @@ -14,7 +14,7 @@ - + diff --git a/docs/cover-square.svg b/docs/cover-square.svg index 1274332..9bd7ec9 100644 --- a/docs/cover-square.svg +++ b/docs/cover-square.svg @@ -45,7 +45,7 @@ Web - + diff --git a/docs/cover.svg b/docs/cover.svg index 743b77b..bd3d120 100644 --- a/docs/cover.svg +++ b/docs/cover.svg @@ -45,7 +45,7 @@ Web - + diff --git a/go.mod b/go.mod deleted file mode 100644 index dcfe93b..0000000 --- a/go.mod +++ /dev/null @@ -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 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 3b7389a..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/internal/auth/password.go b/internal/auth/password.go deleted file mode 100644 index 97c9ece..0000000 --- a/internal/auth/password.go +++ /dev/null @@ -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 -} diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go deleted file mode 100644 index a037b46..0000000 --- a/internal/auth/tokens.go +++ /dev/null @@ -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) } diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 5ddc8ec..0000000 --- a/internal/config/config.go +++ /dev/null @@ -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 -} diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go deleted file mode 100644 index 0b192d3..0000000 --- a/internal/httpapi/auth_handlers.go +++ /dev/null @@ -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 -} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go deleted file mode 100644 index 27c1591..0000000 --- a/internal/httpapi/server.go +++ /dev/null @@ -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 -} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go deleted file mode 100644 index 07b4028..0000000 --- a/internal/httpapi/server_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/httpapi/sync_handler.go b/internal/httpapi/sync_handler.go deleted file mode 100644 index d3c8f55..0000000 --- a/internal/httpapi/sync_handler.go +++ /dev/null @@ -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(¤tUpdated, ¤tVersion) - 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() -} diff --git a/internal/httpapi/web.go b/internal/httpapi/web.go deleted file mode 100644 index d593821..0000000 --- a/internal/httpapi/web.go +++ /dev/null @@ -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) -} diff --git a/src/database.rs b/src/database.rs index 9e8d864..07b9369 100644 --- a/src/database.rs +++ b/src/database.rs @@ -114,17 +114,10 @@ mod tests { let path = directory.join("existing.sqlite"); { let db = Connection::open(&path).unwrap(); - // Use the original schema, so a divergence between implementations - // is caught even when Rust's migration is internally consistent. - let go_source = include_str!("../internal/database/database.go"); - let schema = go_source - .split("const schema = `") - .nth(1) - .unwrap() - .split('`') - .next() + // Use the original Go schema, so a divergence is caught even when + // Rust's migration is internally consistent. + db.execute_batch(include_str!("../tests/fixtures/go_schema_v1.sql")) .unwrap(); - db.execute_batch(schema).unwrap(); db.execute_batch( "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'); diff --git a/src/server.rs b/src/server.rs index 1dde41b..b6dc94c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -687,27 +687,24 @@ fn web(path: &str, headers: &HeaderMap) -> Response { } 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", ), "/app.js" => ( - include_bytes!("../internal/httpapi/web/app.js"), + include_bytes!("../web/app.js"), "text/javascript; charset=utf-8", ), "/service-worker.js" => ( - include_bytes!("../internal/httpapi/web/service-worker.js"), + include_bytes!("../web/service-worker.js"), "text/javascript; charset=utf-8", ), "/styles.css" => ( - include_bytes!("../internal/httpapi/web/styles.css"), + include_bytes!("../web/styles.css"), "text/css; charset=utf-8", ), - "/icon.svg" => ( - include_bytes!("../internal/httpapi/web/icon.svg"), - "image/svg+xml", - ), + "/icon.svg" => (include_bytes!("../web/icon.svg"), "image/svg+xml"), "/manifest.webmanifest" => ( - include_bytes!("../internal/httpapi/web/manifest.webmanifest"), + include_bytes!("../web/manifest.webmanifest"), "text/plain; charset=utf-8", ), _ => return plain(404, "404 page not found\n"), diff --git a/internal/database/database.go b/tests/fixtures/go_schema_v1.sql similarity index 56% rename from internal/database/database.go rename to tests/fixtures/go_schema_v1.sql index b1c001c..25dec1a 100644 --- a/internal/database/database.go +++ b/tests/fixtures/go_schema_v1.sql @@ -1,43 +1,5 @@ -package database - -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 = ` +-- 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. CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE COLLATE NOCASE, @@ -74,7 +36,3 @@ CREATE TABLE IF NOT EXISTS changes ( version INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence); -` - _, err := db.ExecContext(ctx, schema) - return err -} diff --git a/tools/compatibility.py b/tools/compatibility.py index f68bb49..936f42a 100644 --- a/tools/compatibility.py +++ b/tools/compatibility.py @@ -1,6 +1,13 @@ #!/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 ast import base64 import concurrent.futures import contextlib @@ -9,6 +16,7 @@ import hashlib import hmac import json import os +import pprint from pathlib import Path import socket import sqlite3 @@ -17,6 +25,7 @@ import tempfile import time ROOT = Path(__file__).resolve().parents[1] +EXPECTED = Path(__file__).with_name('compatibility_expected.txt') SECRET = 'compatibility-test-secret-not-for-deployment-0123456789' PASSWORD = 'compatibility password' @@ -370,38 +379,52 @@ def raw_json_scenarios(binary, database): 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(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--go-binary', type=Path) - parser.add_argument('--rust-binary', type=Path, default=ROOT / 'target/debug/balance-server') + parser.add_argument('--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() + binary = args.binary.resolve() + assert binary.is_file(), f'Build first: cargo build --locked (missing {binary})' with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory: directory = Path(directory) - go = args.go_binary.resolve() if args.go_binary else directory / 'go-server' - if args.go_binary is None: - subprocess.run(['go','build','-o',str(go),'./cmd/balance-server'], cwd=ROOT, check=True) - rust = args.rust_binary.resolve() - assert rust.is_file(), f'Build Rust first: cargo build --locked (missing {rust})' - print('Running Go reference scenarios...', flush=True) - expected = scenario(go, directory / 'go.db') - print('Running Rust scenarios...', flush=True) - actual = scenario(rust, directory / 'rust.db') - for key in expected: - assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' - print('Comparing HTTP/JSON edge cases...', flush=True) - expected = edge_scenarios(go, directory / 'go-edge.db') - actual = edge_scenarios(rust, directory / 'rust-edge.db') - for key in expected: - assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' - print('Comparing raw JSON payload and Unicode preservation...', flush=True) - expected = raw_json_scenarios(go, directory / 'go-raw.db') - actual = raw_json_scenarios(rust, directory / 'rust-raw.db') - for key in expected: - assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' - for first, second, name in [(go,rust,'go-rust'),(rust,go,'rust-go')]: - 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') + actual = {} + print('Running HTTP/JSON scenarios...', flush=True) + actual['scenario'] = scenario(binary, directory / 'scenario.db') + print('Running HTTP/JSON edge cases...', flush=True) + actual['edge'] = edge_scenarios(binary, directory / 'edge.db') + print('Running raw JSON payload and Unicode preservation...', flush=True) + actual['raw'] = raw_json_scenarios(binary, directory / 'raw.db') + actual = web_assets(actual) + if args.update: + EXPECTED.write_text(pprint.pformat(actual, width=100) + '\n') + print(f'Recorded {EXPECTED.relative_to(ROOT)}') + else: + expected = ast.literal_eval(EXPECTED.read_text()) + for group in expected: + for key in expected[group]: + assert actual[group].get(key) == expected[group][key], ( + f'Reference mismatch: {group} {key}\n' + f'Expected: {expected[group][key]!r}\nActual: {actual[group].get(key)!r}') + assert actual[group].keys() == expected[group].keys(), f'Scenario set changed in {group}; re-record with --update' + print('Checking SQLite persistence across restarts...', flush=True) + takeover(binary, binary, directory / 'restart.db') + print('PASS: HTTP/JSON reference scenarios and SQLite persistence') if __name__ == '__main__': diff --git a/tools/compatibility_expected.txt b/tools/compatibility_expected.txt new file mode 100644 index 0000000..7f5749e --- /dev/null +++ b/tools/compatibility_expected.txt @@ -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'Temporary Redirect.\n\n'), + ('path', '/a/../app.js'): (307, + {'content-type': 'text/html; charset=utf-8', + 'location': '/app.js'}, + b'Temporary Redirect.\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'Temporary Redirect.\n\n'), + ('path', '/v1//me'): (307, + {'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8', + 'location': '/v1/me'}, + b'Temporary Redirect.\n\n'), + ('range', 'bytes=-10'): (206, + {'accept-ranges': 'bytes', + 'content-range': 'bytes 1112-1121/1122', + 'content-type': 'image/svg+xml'}, + b'/>\n\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'\n Balance\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\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'}}}} diff --git a/internal/httpapi/web/app.js b/web/app.js similarity index 100% rename from internal/httpapi/web/app.js rename to web/app.js diff --git a/internal/httpapi/web/icon.svg b/web/icon.svg similarity index 100% rename from internal/httpapi/web/icon.svg rename to web/icon.svg diff --git a/internal/httpapi/web/index.html b/web/index.html similarity index 100% rename from internal/httpapi/web/index.html rename to web/index.html diff --git a/internal/httpapi/web/manifest.webmanifest b/web/manifest.webmanifest similarity index 100% rename from internal/httpapi/web/manifest.webmanifest rename to web/manifest.webmanifest diff --git a/internal/httpapi/web/service-worker.js b/web/service-worker.js similarity index 100% rename from internal/httpapi/web/service-worker.js rename to web/service-worker.js diff --git a/internal/httpapi/web/styles.css b/web/styles.css similarity index 100% rename from internal/httpapi/web/styles.css rename to web/styles.css