Compare commits

4 Commits
Author SHA1 Message Date
wt f97cdf0f2e Merge pull request 'Rust-only server, multi-arch Docker image, CI and docs' (#2) from develop into main
CI / Rust (fmt / clippy / test / build) (push) Successful in 53s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 1m30s
Reviewed-on: #2
2026-10-30 09:35:03 +00:00
wtandClaude Opus 5.5 10272f3851 Remove the Go reference server; keep only Rust
CI / Rust (fmt / clippy / test / build) (push) Successful in 1m41s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 1m16s
CI / Rust (fmt / clippy / test / build) (pull_request) Successful in 47s
CI / HTTP/JSON reference + SQLite persistence (pull_request) Successful in 1m34s
The Go implementation is preserved in the v1.0.0 tag. Its observable
behaviour stays the API reference: responses recorded from the Go server
live in tools/compatibility_expected.txt, and tools/compatibility.py now
compares the Rust server against them (--update re-records after an
intentional change). The Go SQLite schema is frozen in
tests/fixtures/go_schema_v1.sql for the existing-database test.

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

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

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

No files matched your search

+6 -46
View File
@@ -9,10 +9,6 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
# Pin the toolchain to the declared MSRV so CI matches production. # Pin the toolchain to the declared MSRV so CI matches production.
RUST_TOOLCHAIN: 1.90.0 RUST_TOOLCHAIN: 1.90.0
# The differential check compares Rust against net/http behaviour, which
# changes between Go releases (e.g. ServeMux redirects 301 -> 307), so the
# reference must be built with the same Go the Rust port mirrors.
GO_VERSION: "1.27.3"
jobs: jobs:
rust: rust:
@@ -58,39 +54,10 @@ jobs:
path: target/release/balance-server path: target/release/balance-server
if-no-files-found: error if-no-files-found: error
go:
name: Go (test / vet / build)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Test
run: go test ./...
- name: Vet
run: go vet ./...
- name: Build
run: go build ./...
compatibility: compatibility:
name: Go ↔ Rust differential + SQLite takeover name: HTTP/JSON reference + SQLite persistence
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [rust, go] needs: [rust]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -100,11 +67,6 @@ jobs:
with: with:
toolchain: ${{ env.RUST_TOOLCHAIN }} toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install Python - name: Install Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@@ -121,10 +83,8 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-
- name: Build Rust (debug) and Go reference - name: Build Rust (debug)
run: | run: cargo build --locked
cargo build --locked
go build -o target/integration/go-server ./cmd/balance-server
- name: Run differential compatibility scenarios - name: Compare against the recorded Go reference
run: python3 tools/compatibility.py --rust-binary target/debug/balance-server run: python3 tools/compatibility.py --binary target/debug/balance-server
+11 -26
View File
@@ -8,28 +8,14 @@
## Быстрая карта проекта ## Быстрая карта проекта
Production-сервер написан на Rust 1.90+. Go 1.25.0+ сохранён как Сервер написан на Rust 1.90+. Исходная Go-реализация удалена после v1.0.0
эталон совместимости API/SQLite и вариант отката. (её исходники остались в теге `v1.0.0`), но её поведение остаётся эталоном API:
ответы Go записаны в `tools/compatibility_expected.txt`, а исходная SQLite-схема
Go — в `tests/fixtures/go_schema_v1.sql`. Оба файла не правь вручную.
Основные Rust-модули: `src/main.rs`, `runtime.rs`, `config.rs`, Основные Rust-модули: `src/main.rs`, `runtime.rs`, `config.rs`,
`database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`. `database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`.
Веб-клиент из `internal/httpapi/web` встроен в Rust executable. Веб-клиент из `web/` встроен в Rust executable.
Основные Go-файлы:
```text
- internal/database/database.go
- internal/config/config.go
- internal/auth/tokens.go
- internal/auth/password.go
- internal/httpapi/auth_handlers.go
- internal/httpapi/server.go
- internal/httpapi/sync_handler.go
- internal/httpapi/web.go
- internal/httpapi/server_test.go
```
Количество Go-файлов: **9**.
Обнаруженные технологии/интеграции: SQLite, Docker. Обнаруженные технологии/интеграции: SQLite, Docker.
@@ -106,12 +92,13 @@ cargo fmt --check
cargo test --locked cargo test --locked
cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets -- -D warnings
cargo build --locked --release cargo build --locked --release
python3 tools/compatibility.py --rust-binary target/release/balance-server python3 tools/compatibility.py --binary target/release/balance-server
go test ./...
go vet ./...
go build ./...
``` ```
Если `tools/compatibility.py` нашёл расхождение, это изменение видно клиентам.
Перезаписывай эталон (`--update`) только для намеренного изменения API и
после обновления документации.
Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI. Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI.
### 8. Документация ### 8. Документация
@@ -130,8 +117,6 @@ go build ./...
- [ ] Sync остаётся идемпотентным. - [ ] Sync остаётся идемпотентным.
- [ ] DB migration учтена. - [ ] DB migration учтена.
- [ ] Concurrent requests безопасны. - [ ] Concurrent requests безопасны.
- [ ] `go test ./...` проходит.
- [ ] Rust tests, fmt, clippy и release build проходят. - [ ] Rust tests, fmt, clippy и release build проходят.
- [ ] Differential HTTP/JSON и Go ↔ Rust SQLite takeover проходят. - [ ] `tools/compatibility.py` совпадает с записанным эталоном Go.
- [ ] `go vet ./...` проходит.
- [ ] Документация обновлена. - [ ] Документация обновлена.
+1 -1
View File
@@ -23,7 +23,7 @@ RUN set -eu; \
WORKDIR /src WORKDIR /src
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY src ./src COPY src ./src
COPY internal/httpapi/web ./internal/httpapi/web COPY web ./web
RUN . /cross.env \ RUN . /cross.env \
&& cargo build --locked --release --target "$RUST_TARGET" \ && cargo build --locked --release --target "$RUST_TARGET" \
&& cp "target/$RUST_TARGET/release/balance-server" /balance-server && cp "target/$RUST_TARGET/release/balance-server" /balance-server
+14 -14
View File
@@ -1,5 +1,7 @@
# Balance Server # Balance Server
![Balance Server — self-hosted sync and accounts for Balance](docs/cover.png)
Self-hosted synchronization, account and web server for the Balance iOS, macOS, watchOS and Android applications. It is a single Rust process with a bundled SQLite engine and a complete browser client, and runs on Linux, Windows and macOS. Self-hosted synchronization, account and web server for the Balance iOS, macOS, watchOS and Android applications. It is a single Rust process with a bundled SQLite engine and a complete browser client, and runs on Linux, Windows and macOS.
## Quick start with Docker ## Quick start with Docker
@@ -43,8 +45,7 @@ $env:BALANCE_DB_PATH = ".\data\balance.db"
For another platform, build on that platform or install its Rust target and C toolchain For another platform, build on that platform or install its Rust target and C toolchain
for bundled SQLite. `cargo build --locked --release --target <target>` requires the for bundled SQLite. `cargo build --locked --release --target <target>` requires the
matching linker and C compiler. The retained Go implementation is a compatibility matching linker and C compiler.
reference and rollback option: `go build -o balance-server-go ./cmd/balance-server`.
After starting the binary, open `http://localhost:8080` in a browser. The web assets are embedded into the executable, so there is no separate frontend build, Node.js process or static directory to deploy. After starting the binary, open `http://localhost:8080` in a browser. The web assets are embedded into the executable, so there is no separate frontend build, Node.js process or static directory to deploy.
@@ -112,9 +113,10 @@ The sync endpoint accepts transactions, custom categories and monthly budgets. T
## Compatibility and migration ## Compatibility and migration
The Rust server retains the HTTP/JSON API, embedded web client, environment The Rust server replaced the original Go implementation and retains its HTTP/JSON
variables, SQLite schema, Argon2id password hashes, JWT format, refresh sessions API, embedded web client, environment variables, SQLite schema, Argon2id password
and sync cursor semantics of the Go implementation. Docker Compose still maps hashes, JWT format, refresh sessions and sync cursor semantics. The Go sources were
removed after v1.0.0; that tag still contains them for reference or rollback. Docker Compose still maps
host port **8123** to container port **8080** and keeps `/data` in `balance-data`. host port **8123** to container port **8080** and keeps `/data` in `balance-data`.
Direct binary execution defaults to port 8080. Direct binary execution defaults to port 8080.
@@ -124,8 +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. Existing accounts, access/refresh tokens and saved device cursors remain usable.
See [deployment and rollback](docs/DEPLOYMENT.md) for the full procedure. See [deployment and rollback](docs/DEPLOYMENT.md) for the full procedure.
Verify the implementations locally with Go 1.27.3 (the version CI uses for the Verify the server locally with Rust and Python 3:
differential check), Rust and Python 3:
```sh ```sh
cargo test --locked cargo test --locked
@@ -133,12 +134,11 @@ cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets -- -D warnings
cargo build --locked cargo build --locked
python3 tools/compatibility.py python3 tools/compatibility.py
go test ./...
go vet ./...
go build ./...
``` ```
The Python standard-library suite builds the Go reference, starts both binaries The Python standard-library suite starts the server on temporary loopback ports
on temporary loopback ports and databases, compares API responses and assets, and databases, compares its API responses and assets with the responses recorded
and tests SQLite takeover and rollback in both directions. To use release or from the original Go server (`tools/compatibility_expected.txt`), and checks that
prebuilt executables, pass `--rust-binary PATH` and optionally `--go-binary PATH`. SQLite data survives restarts. To test a release or prebuilt executable, pass
`--binary PATH`. After an intentional API change, review the difference and
re-record the reference with `--update`.
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"balance-server/internal/config"
"balance-server/internal/database"
"balance-server/internal/httpapi"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("configuration error", "error", err)
os.Exit(1)
}
db, err := database.Open(cfg.DatabasePath)
if err != nil {
logger.Error("database error", "error", err)
os.Exit(1)
}
defer db.Close()
server := &http.Server{
Addr: cfg.Address,
Handler: httpapi.New(cfg, db, logger),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
logger.Info("Balance server started", "address", cfg.Address, "database", cfg.DatabasePath)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
}
+2 -9
View File
@@ -23,16 +23,9 @@ Android ───────────┘
## Исходный код ## Исходный код
Production runtime — один Rust-процесс: `src/main.rs`, `runtime.rs`, `config.rs`, `database.rs`, `auth.rs`, `sync.rs`, `server.rs`, `go_json.rs`. SQLite bundled через rusqlite; HTTP обслуживается axum/hyper. `runtime.rs` сохраняет socket deadlines Go (header 5s, read 15s, write 30s, idle 60s) и ограничивает graceful shutdown десятью секундами. `go_json.rs` сохраняет особенности encoding/json и исходные payload bytes. Статические файлы из `internal/httpapi/web` встроены при компиляции. Go-код сохранён как эталон совместимости и вариант rollback. Production runtime — один Rust-процесс: `src/main.rs`, `runtime.rs`, `config.rs`, `database.rs`, `auth.rs`, `sync.rs`, `server.rs`, `go_json.rs`. 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-реализации: Исходная Go-реализация удалена после v1.0.0 (исходники остались в теге `v1.0.0`). Её наблюдаемое поведение зафиксировано в `tools/compatibility_expected.txt`, а SQLite-схема — в `tests/fixtures/go_schema_v1.sql`; Rust сверяется с обоими в CI.
```text
- internal/auth
- internal/config
- internal/database
- internal/httpapi
```
Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API. Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API.
+5 -20
View File
@@ -2,27 +2,12 @@
Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ. Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ.
## Найденные определения моделей/schema ## Где определена schema
- internal/database/database.go - `src/database.rs` — создание и открытие схемы SQLite;
- internal/database/database.go - `tests/fixtures/go_schema_v1.sql` — исходная схема Go-сервера v1.0.0; Rust обязан открывать такие базы без изменений;
- internal/database/database.go - `src/sync.rs` — валидация и сериализация синхронизируемых записей;
- internal/config/config.go - `src/auth.rs` — пользователи, пароли и refresh-сессии.
- internal/auth/tokens.go
- internal/auth/tokens.go
- internal/httpapi/auth_handlers.go
- internal/httpapi/auth_handlers.go
- internal/httpapi/auth_handlers.go
- internal/httpapi/server.go
- internal/httpapi/server.go
- internal/httpapi/server.go
- internal/httpapi/sync_handler.go
- internal/httpapi/sync_handler.go
- internal/httpapi/sync_handler.go
- internal/httpapi/web.go
- internal/httpapi/server_test.go
- internal/httpapi/server_test.go
- internal/httpapi/server_test.go
## Общие сущности ## Общие сущности
+11 -11
View File
@@ -3,9 +3,9 @@
## Runtime and configuration ## Runtime and configuration
The production executable is the Rust server, built with `cargo build --locked The production executable is the Rust server, built with `cargo build --locked
--release`. SQLite is bundled into the executable. The Go implementation remains --release`. SQLite is bundled into the executable. The original Go implementation
in `cmd/balance-server` and `internal/` as a compatibility reference and rollback was removed after v1.0.0; the `v1.0.0` tag still contains its sources for the
option; build it with `go build -o balance-server-go ./cmd/balance-server`. migration and rollback below (`go build -o balance-server-go ./cmd/balance-server`).
The Dockerfile uses a Rust 1.90 Debian Bookworm builder and a Debian Bookworm slim The Dockerfile uses a Rust 1.90 Debian Bookworm builder and a Debian Bookworm slim
runtime, running as the unprivileged `balance` user. The image embeds the existing runtime, running as the unprivileged `balance` user. The image embeds the existing
@@ -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 Stop Rust first. Start the retained Go executable/image with the same current
database directory and secret. Rust-written accounts, password hashes, refresh database directory and secret. Rust-written accounts, password hashes, refresh
sessions, records, tombstones and cursors remain readable by Go. Check health, sessions, records, tombstones and cursors remain readable by Go. Check health,
login, refresh and sync again. The compatibility suite exercises both directions, login, refresh and sync again. Before the Go sources were removed, the
including a second takeover back to the original implementation. compatibility suite verified takeover and rollback in both directions; Rust still
writes the same schema (`tests/fixtures/go_schema_v1.sql`).
For database corruption or an unrelated failed migration, stop all writers before For database corruption or an unrelated failed migration, stop all writers before
restoring the complete backup directory. Restoring an older backup discards restoring the complete backup directory. Restoring an older backup discards
@@ -77,12 +78,11 @@ an active database or delete a Compose volume as part of an upgrade.
## Validation and operations ## Validation and operations
Before release run `cargo test --locked`, `cargo build --locked --release`, Before release run `cargo test --locked`, `cargo build --locked --release`,
`python3 tools/compatibility.py --rust-binary target/release/balance-server`, and and `python3 tools/compatibility.py --binary target/release/balance-server`.
the retained reference checks `go test ./...`, `go vet ./...`, `go build ./...`. The suite uses temporary databases and loopback listeners, compares
The differential suite uses temporary databases and loopback listeners, compares API/errors/security headers/assets with the recorded Go reference, tests browser
API/errors/security headers/assets, tests browser cookies, pagination, LWW, cookies, pagination, LWW, tombstones, concurrent pushes, token expiration, refresh
tombstones, concurrent pushes, token expiration, refresh rotation, rate limiting, rotation, rate limiting, and SQLite persistence across restarts.
and shared-database takeover/rollback in both directions.
Keep regular encrypted backups with retention and restore drills. Monitor HTTP Keep regular encrypted backups with retention and restore drills. Monitor HTTP
5xx, sync errors, authentication failures, latency, database/change-log growth 5xx, sync errors, authentication failures, latency, database/change-log growth
+8 -9
View File
@@ -2,28 +2,26 @@
## Требования ## Требования
Production-сервер использует Rust 1.90+ и bundled SQLite. Go 1.25+ нужен для сохранённой эталонной реализации; differential-проверки (`tools/compatibility.py`) требуют Go 1.27.3, как в CI: поведение `net/http` (например, код редиректа ServeMux 301/307) зависит от версии Go. Python 3 используется без сторонних пакетов. Сервер использует Rust 1.90+ и bundled SQLite. Python 3 нужен для `tools/compatibility.py` и используется без сторонних пакетов. Go больше не нужен.
Проверить локальную версию: Проверить локальную версию:
```bash ```bash
rustc --version rustc --version
cargo --version cargo --version
go version python3 --version
``` ```
## Установка зависимостей ## Установка зависимостей
```bash ```bash
cargo fetch --locked cargo fetch --locked
go mod download
``` ```
## Сборка ## Сборка
```bash ```bash
cargo build --locked cargo build --locked
go build ./...
``` ```
## Тесты ## Тесты
@@ -32,13 +30,15 @@ go build ./...
cargo test --locked cargo test --locked
cargo build --locked cargo build --locked
python3 tools/compatibility.py python3 tools/compatibility.py
go test ./...
``` ```
С race detector: `tools/compatibility.py` запускает сервер на временных портах и базах и сравнивает
ответы API и статику с ответами исходного Go-сервера, записанными в
`tools/compatibility_expected.txt`. Расхождение означает изменение, видимое
клиентам. Если изменение намеренное, проверьте diff и перезапишите эталон:
```bash ```bash
go test -race ./... python3 tools/compatibility.py --update
``` ```
## Static checks ## Static checks
@@ -46,7 +46,6 @@ go test -race ./...
```bash ```bash
cargo fmt --check cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets -- -D warnings
go vet ./...
``` ```
Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline. Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline.
@@ -89,7 +88,7 @@ cargo clippy --locked --all-targets -- -D warnings
↓ ↓
cargo build --locked --release cargo build --locked --release
↓ ↓
python3 tools/compatibility.py --rust-binary target/release/balance-server python3 tools/compatibility.py --binary target/release/balance-server
↓ ↓
container/package container/package
↓ ↓
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

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

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

-21
View File
@@ -1,21 +0,0 @@
module balance-server
go 1.25.0
require (
golang.org/x/crypto v0.41.0
modernc.org/sqlite v1.39.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
-51
View File
@@ -1,51 +0,0 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-61
View File
@@ -1,61 +0,0 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argonMemory = 64 * 1024
argonTime = 3
argonThreads = 2
argonKeyLen = 32
)
func HashPassword(password string) (string, error) {
if len(password) < 8 {
return "", errors.New("password must contain at least 8 characters")
}
if len(password) > 1024 {
return "", errors.New("password is too long")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
}
func VerifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
return false
}
var memory uint32
var iterations uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
return false
}
if memory > 256*1024 || iterations > 10 || threads > 16 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(expected) == 0 {
return false
}
actual := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
-90
View File
@@ -1,90 +0,0 @@
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
)
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Issuer string `json:"iss"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
ID string `json:"jti"`
}
type TokenManager struct {
secret []byte
accessTTL time.Duration
}
func NewTokenManager(secret string, accessTTL time.Duration) TokenManager {
return TokenManager{secret: []byte(secret), accessTTL: accessTTL}
}
func (manager TokenManager) AccessToken(userID, email string) (string, time.Time, error) {
now := time.Now().UTC()
expires := now.Add(manager.accessTTL)
id, err := RandomToken(16)
if err != nil {
return "", time.Time{}, err
}
header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
claims, _ := json.Marshal(Claims{Subject: userID, Email: email, Issuer: "balance-server", Issued: now.Unix(), Expires: expires.Unix(), ID: id})
unsigned := encode(header) + "." + encode(claims)
signature := manager.sign(unsigned)
return unsigned + "." + signature, expires, nil
}
func (manager TokenManager) Verify(token string) (Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return Claims{}, errors.New("invalid token")
}
unsigned := parts[0] + "." + parts[1]
expected := manager.sign(unsigned)
if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
return Claims{}, errors.New("invalid token signature")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Claims{}, errors.New("invalid token payload")
}
var claims Claims
if err := json.Unmarshal(payload, &claims); err != nil {
return Claims{}, errors.New("invalid token claims")
}
if claims.Issuer != "balance-server" || claims.Subject == "" || time.Now().UTC().Unix() >= claims.Expires {
return Claims{}, errors.New("expired or invalid token")
}
return claims, nil
}
func RandomToken(bytes int) (string, error) {
value := make([]byte, bytes)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func TokenHash(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
func (manager TokenManager) sign(value string) string {
mac := hmac.New(sha256.New, manager.secret)
_, _ = mac.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func encode(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
-93
View File
@@ -1,93 +0,0 @@
package config
import (
"errors"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Address string
DatabasePath string
JWTSecret string
AccessTTL time.Duration
RefreshTTL time.Duration
AllowRegistration bool
CORSOrigins []string
}
func Load() (Config, error) {
accessTTL, err := duration("BALANCE_ACCESS_TTL", 15*time.Minute)
if err != nil {
return Config{}, err
}
refreshTTL, err := duration("BALANCE_REFRESH_TTL", 30*24*time.Hour)
if err != nil {
return Config{}, err
}
allowRegistration, err := boolean("BALANCE_ALLOW_REGISTRATION", true)
if err != nil {
return Config{}, err
}
cfg := Config{
Address: env("BALANCE_ADDR", ":8080"),
DatabasePath: env("BALANCE_DB_PATH", "./data/balance.db"),
JWTSecret: strings.TrimSpace(os.Getenv("BALANCE_JWT_SECRET")),
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
AllowRegistration: allowRegistration,
CORSOrigins: split(os.Getenv("BALANCE_CORS_ORIGINS")),
}
if len(cfg.JWTSecret) < 32 {
return Config{}, errors.New("BALANCE_JWT_SECRET must contain at least 32 characters")
}
return cfg, nil
}
func env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func duration(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, errors.New(key + " must be a Go duration such as 15m or 720h")
}
return parsed, nil
}
func boolean(key string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, errors.New(key + " must be true or false")
}
return parsed, nil
}
func split(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if item := strings.TrimSpace(part); item != "" {
result = append(result, item)
}
}
return result
}
-245
View File
@@ -1,245 +0,0 @@
package httpapi
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"balance-server/internal/auth"
)
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
type refreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
type userResponse struct {
ID string `json:"id"`
Email string `json:"email"`
}
type sessionResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt time.Time `json:"expiresAt"`
User userResponse `json:"user"`
}
func (api *API) register(w http.ResponseWriter, r *http.Request) {
if !api.cfg.AllowRegistration {
writeError(w, http.StatusForbidden, "registration_disabled", "Registration is disabled")
return
}
var request credentials
if !decodeJSON(w, r, &request) {
return
}
email, err := normalizeEmail(request.Email)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_email", "Enter a valid email address")
return
}
passwordHash, err := auth.HashPassword(request.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_password", err.Error())
return
}
userID, err := identifier()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account")
return
}
_, err = api.db.ExecContext(r.Context(), `INSERT INTO users(id,email,password_hash,created_at) VALUES(?,?,?,?)`, userID, email, passwordHash, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
if containsUnique(err) {
writeError(w, http.StatusConflict, "email_exists", "An account with this email already exists")
return
}
api.logger.Error("create user", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account")
return
}
response, err := api.createSession(r, userID, email)
if err != nil {
api.logger.Error("create session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session")
return
}
api.setSessionCookies(w, r, response)
writeJSON(w, http.StatusCreated, response)
}
func (api *API) login(w http.ResponseWriter, r *http.Request) {
var request credentials
if !decodeJSON(w, r, &request) {
return
}
email, err := normalizeEmail(request.Email)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect")
return
}
var userID, passwordHash string
err = api.db.QueryRowContext(r.Context(), `SELECT id,password_hash FROM users WHERE email=?`, email).Scan(&userID, &passwordHash)
if err != nil || !auth.VerifyPassword(passwordHash, request.Password) {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect")
return
}
response, err := api.createSession(r, userID, email)
if err != nil {
api.logger.Error("create session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session")
return
}
api.setSessionCookies(w, r, response)
writeJSON(w, http.StatusOK, response)
}
func (api *API) refresh(w http.ResponseWriter, r *http.Request) {
var request refreshRequest
if !decodeJSON(w, r, &request) {
return
}
request.RefreshToken = api.refreshToken(r, request.RefreshToken)
if request.RefreshToken == "" {
writeError(w, http.StatusBadRequest, "refresh_token_required", "Refresh token is required")
return
}
tx, err := api.db.BeginTx(r.Context(), nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session")
return
}
defer tx.Rollback()
var userID, email, expiresText string
err = tx.QueryRowContext(r.Context(), `SELECT u.id,u.email,t.expires_at FROM refresh_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=? AND t.revoked_at IS NULL`, auth.TokenHash(request.RefreshToken)).Scan(&userID, &email, &expiresText)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired")
return
}
expires, err := time.Parse(time.RFC3339Nano, expiresText)
if err != nil || !time.Now().UTC().Before(expires) {
writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired")
return
}
_, err = tx.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=?`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session")
return
}
response, err := api.createSessionWithExecutor(r, userID, email, tx)
if err != nil || tx.Commit() != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session")
return
}
api.setSessionCookies(w, r, response)
writeJSON(w, http.StatusOK, response)
}
func (api *API) logout(w http.ResponseWriter, r *http.Request) {
var request refreshRequest
if !decodeJSON(w, r, &request) {
return
}
request.RefreshToken = api.refreshToken(r, request.RefreshToken)
if request.RefreshToken != "" {
_, _ = api.db.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken))
}
api.clearSessionCookies(w, r)
w.WriteHeader(http.StatusNoContent)
}
func (api *API) me(w http.ResponseWriter, r *http.Request) {
value := claims(r)
writeJSON(w, http.StatusOK, userResponse{ID: value.Subject, Email: value.Email})
}
func (api *API) createSession(r *http.Request, userID, email string) (sessionResponse, error) {
return api.createSessionWithExecutor(r, userID, email, api.db)
}
func (api *API) createSessionWithExecutor(r *http.Request, userID, email string, executor interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}) (sessionResponse, error) {
accessToken, expiresAt, err := api.tokens.AccessToken(userID, email)
if err != nil {
return sessionResponse{}, err
}
refreshToken, err := auth.RandomToken(48)
if err != nil {
return sessionResponse{}, err
}
refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL)
_, err = executor.ExecContext(r.Context(), `INSERT INTO refresh_tokens(token_hash,user_id,expires_at,created_at) VALUES(?,?,?,?)`, auth.TokenHash(refreshToken), userID, refreshExpires.Format(time.RFC3339Nano), time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return sessionResponse{}, err
}
return sessionResponse{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, User: userResponse{ID: userID, Email: email}}, nil
}
func identifier() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}
func containsUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
const (
accessCookieName = "balance_access"
refreshCookieName = "balance_refresh"
)
func (api *API) setSessionCookies(w http.ResponseWriter, r *http.Request, session sessionResponse) {
secure := requestIsSecure(r)
http.SetCookie(w, &http.Cookie{
Name: accessCookieName, Value: session.AccessToken, Path: "/", HttpOnly: true,
Secure: secure, SameSite: http.SameSiteStrictMode,
Expires: session.ExpiresAt, MaxAge: maxAge(session.ExpiresAt),
})
refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL)
http.SetCookie(w, &http.Cookie{
Name: refreshCookieName, Value: session.RefreshToken, Path: "/", HttpOnly: true,
Secure: secure, SameSite: http.SameSiteStrictMode,
Expires: refreshExpires, MaxAge: maxAge(refreshExpires),
})
}
func (api *API) clearSessionCookies(w http.ResponseWriter, r *http.Request) {
for _, name := range []string{accessCookieName, refreshCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", HttpOnly: true, Secure: requestIsSecure(r),
SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(1, 0),
})
}
}
func (api *API) refreshToken(r *http.Request, value string) string {
if value = strings.TrimSpace(value); value != "" {
return value
}
if cookie, err := r.Cookie(refreshCookieName); err == nil {
return strings.TrimSpace(cookie.Value)
}
return ""
}
func requestIsSecure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func maxAge(expires time.Time) int {
seconds := int(time.Until(expires).Seconds())
if seconds < 1 {
return 1
}
return seconds
}
-185
View File
@@ -1,185 +0,0 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"balance-server/internal/auth"
"balance-server/internal/config"
)
type contextKey string
const claimsKey contextKey = "claims"
type API struct {
cfg config.Config
db *sql.DB
tokens auth.TokenManager
logger *slog.Logger
limiter *rateLimiter
}
func New(cfg config.Config, db *sql.DB, logger *slog.Logger) http.Handler {
api := &API{cfg: cfg, db: db, tokens: auth.NewTokenManager(cfg.JWTSecret, cfg.AccessTTL), logger: logger, limiter: newRateLimiter()}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", api.health)
mux.Handle("POST /v1/auth/register", api.limited(http.HandlerFunc(api.register)))
mux.Handle("POST /v1/auth/login", api.limited(http.HandlerFunc(api.login)))
mux.Handle("POST /v1/auth/refresh", api.limited(http.HandlerFunc(api.refresh)))
mux.HandleFunc("POST /v1/auth/logout", api.logout)
mux.Handle("GET /v1/me", api.authorized(http.HandlerFunc(api.me)))
mux.Handle("POST /v1/sync", api.authorized(http.HandlerFunc(api.sync)))
mux.HandleFunc("GET /", api.webApp)
return api.recover(api.security(api.cors(mux)))
}
func (api *API) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": time.Now().UTC()})
}
func (api *API) authorized(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := strings.TrimSpace(r.Header.Get("Authorization"))
token := ""
if strings.HasPrefix(header, "Bearer ") {
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
} else if cookie, err := r.Cookie(accessCookieName); err == nil {
token = strings.TrimSpace(cookie.Value)
}
if token == "" {
writeError(w, http.StatusUnauthorized, "authorization_required", "Authorization Bearer token is required")
return
}
claims, err := api.tokens.Verify(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_token", "Access token is invalid or expired")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
})
}
func claims(r *http.Request) auth.Claims {
value, _ := r.Context().Value(claimsKey).(auth.Claims)
return value
}
func (api *API) security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; manifest-src 'self'; script-src 'self'; style-src 'self'; worker-src 'self'")
if strings.HasPrefix(r.URL.Path, "/v1/") {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
func (api *API) cors(next http.Handler) http.Handler {
allowed := make(map[string]bool, len(api.cfg.CORSOrigins))
for _, origin := range api.cfg.CORSOrigins {
allowed[origin] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (allowed[origin] || allowed["*"]) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (api *API) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if value := recover(); value != nil {
api.logger.Error("request panic", "value", value)
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "Request body is not valid JSON")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}})
}
type rateLimiter struct {
mu sync.Mutex
clients map[string]*rateBucket
}
type rateBucket struct {
started time.Time
count int
}
func newRateLimiter() *rateLimiter { return &rateLimiter{clients: make(map[string]*rateBucket)} }
func (api *API) limited(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
now := time.Now()
api.limiter.mu.Lock()
bucket := api.limiter.clients[host]
if bucket == nil || now.Sub(bucket.started) >= time.Minute {
bucket = &rateBucket{started: now}
api.limiter.clients[host] = bucket
}
bucket.count++
blocked := bucket.count > 30
api.limiter.mu.Unlock()
if blocked {
w.Header().Set("Retry-After", "60")
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many authentication requests")
return
}
next.ServeHTTP(w, r)
})
}
func normalizeEmail(value string) (string, error) {
email := strings.ToLower(strings.TrimSpace(value))
if len(email) < 3 || len(email) > 254 || !strings.Contains(email, "@") {
return "", errors.New("invalid email")
}
return email, nil
}
-204
View File
@@ -1,204 +0,0 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"balance-server/internal/config"
"balance-server/internal/database"
)
func TestRegisterAndSynchronize(t *testing.T) {
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
cfg := config.Config{JWTSecret: "test-secret-that-is-longer-than-thirty-two-characters", AccessTTL: 15 * time.Minute, RefreshTTL: time.Hour, AllowRegistration: true}
server := httptest.NewServer(New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
register := request(t, http.MethodPost, server.URL+"/v1/auth/register", map[string]any{"email": "person@example.com", "password": "correct horse battery staple"}, "")
if register.StatusCode != http.StatusCreated {
t.Fatalf("register status: %d", register.StatusCode)
}
var session sessionResponse
decode(t, register, &session)
if session.AccessToken == "" || session.RefreshToken == "" {
t.Fatal("session tokens are empty")
}
now := time.Now().UTC().Add(-time.Second)
pull := false
syncBody := syncRequest{DeviceID: "test", Pull: &pull, Changes: []syncChange{{Entity: "category", ID: "category-1", UpdatedAt: now, Payload: json.RawMessage(`{"name":"Food"}`)}}}
synchronized := request(t, http.MethodPost, server.URL+"/v1/sync", syncBody, session.AccessToken)
if synchronized.StatusCode != http.StatusOK {
t.Fatalf("sync status: %d", synchronized.StatusCode)
}
var result syncResponse
decode(t, synchronized, &result)
if result.Cursor != 0 || len(result.Changes) != 0 {
t.Fatalf("push-only response included pulled changes: %+v", result)
}
pull = true
synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{DeviceID: "test", Pull: &pull}, session.AccessToken)
if synchronized.StatusCode != http.StatusOK {
t.Fatalf("pull status: %d", synchronized.StatusCode)
}
decode(t, synchronized, &result)
if result.Cursor == 0 || len(result.Changes) != 1 || result.Changes[0].ID != "category-1" {
t.Fatalf("unexpected sync response: %+v", result)
}
pageStart := result.Cursor
pageChanges := make([]syncChange, syncPageSize+1)
for index := range pageChanges {
pageChanges[index] = syncChange{
Entity: "category", ID: "page-" + time.Unix(int64(index), 0).UTC().Format("150405"),
UpdatedAt: now.Add(time.Duration(index+1) * time.Millisecond), Payload: json.RawMessage(`{"name":"Paged"}`),
}
}
pull = false
synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{DeviceID: "test", Pull: &pull, Changes: pageChanges}, session.AccessToken)
if synchronized.StatusCode != http.StatusOK {
t.Fatalf("paged push status: %d", synchronized.StatusCode)
}
synchronized.Body.Close()
pull = true
synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{Cursor: pageStart, DeviceID: "test", Pull: &pull}, session.AccessToken)
decode(t, synchronized, &result)
if len(result.Changes) != syncPageSize || !result.HasMore {
t.Fatalf("first page mismatch: count=%d hasMore=%v", len(result.Changes), result.HasMore)
}
synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{Cursor: result.Cursor, DeviceID: "test", Pull: &pull}, session.AccessToken)
decode(t, synchronized, &result)
if len(result.Changes) != 1 || result.HasMore {
t.Fatalf("last page mismatch: count=%d hasMore=%v", len(result.Changes), result.HasMore)
}
}
func TestEmbeddedWebAppAndCookieSession(t *testing.T) {
db, err := database.Open(filepath.Join(t.TempDir(), "web.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
cfg := config.Config{JWTSecret: "test-secret-that-is-longer-than-thirty-two-characters", AccessTTL: 15 * time.Minute, RefreshTTL: time.Hour, AllowRegistration: true}
server := httptest.NewServer(New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
home, err := http.Get(server.URL + "/")
if err != nil {
t.Fatal(err)
}
homeBody, err := io.ReadAll(home.Body)
home.Body.Close()
if err != nil {
t.Fatal(err)
}
if home.StatusCode != http.StatusOK || !strings.Contains(string(homeBody), "id=\"app-view\"") {
t.Fatalf("web app response: status=%d body=%q", home.StatusCode, string(homeBody))
}
if !strings.Contains(home.Header.Get("Content-Security-Policy"), "script-src 'self'") {
t.Fatal("web app is missing its content security policy")
}
register := request(t, http.MethodPost, server.URL+"/v1/auth/register", map[string]any{"email": "web@example.com", "password": "correct horse battery staple"}, "")
if register.StatusCode != http.StatusCreated {
t.Fatalf("register status: %d", register.StatusCode)
}
cookies := register.Cookies()
var session sessionResponse
decode(t, register, &session)
var accessCookie, refreshCookie *http.Cookie
for _, cookie := range cookies {
switch cookie.Name {
case accessCookieName:
accessCookie = cookie
case refreshCookieName:
refreshCookie = cookie
}
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("insecure session cookie: %+v", cookie)
}
}
if accessCookie == nil || refreshCookie == nil {
t.Fatal("session cookies were not returned")
}
meRequest, err := http.NewRequest(http.MethodGet, server.URL+"/v1/me", nil)
if err != nil {
t.Fatal(err)
}
meRequest.AddCookie(accessCookie)
me, err := http.DefaultClient.Do(meRequest)
if err != nil {
t.Fatal(err)
}
if me.StatusCode != http.StatusOK {
t.Fatalf("cookie authentication status: %d", me.StatusCode)
}
var user userResponse
decode(t, me, &user)
if user.Email != "web@example.com" {
t.Fatalf("unexpected web user: %+v", user)
}
refreshData, err := json.Marshal(refreshRequest{})
if err != nil {
t.Fatal(err)
}
refreshHTTP, err := http.NewRequest(http.MethodPost, server.URL+"/v1/auth/refresh", bytes.NewReader(refreshData))
if err != nil {
t.Fatal(err)
}
refreshHTTP.Header.Set("Content-Type", "application/json")
refreshHTTP.AddCookie(refreshCookie)
refreshed, err := http.DefaultClient.Do(refreshHTTP)
if err != nil {
t.Fatal(err)
}
if refreshed.StatusCode != http.StatusOK {
t.Fatalf("cookie refresh status: %d", refreshed.StatusCode)
}
refreshed.Body.Close()
}
func request(t *testing.T, method, url string, value any, token string) *http.Response {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(method, url, bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return response
}
func decode(t *testing.T, response *http.Response, value any) {
t.Helper()
defer response.Body.Close()
if err := json.NewDecoder(response.Body).Decode(value); err != nil {
t.Fatal(err)
}
}
-169
View File
@@ -1,169 +0,0 @@
package httpapi
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"time"
)
const maxSyncChanges = 5000
const syncPageSize = 500
type syncChange struct {
Entity string `json:"entity"`
ID string `json:"id"`
Deleted bool `json:"deleted"`
UpdatedAt time.Time `json:"updatedAt"`
Payload json.RawMessage `json:"payload,omitempty"`
Version int64 `json:"version,omitempty"`
Sequence int64 `json:"sequence,omitempty"`
}
type syncRequest struct {
Cursor int64 `json:"cursor"`
DeviceID string `json:"deviceId"`
Changes []syncChange `json:"changes"`
Pull *bool `json:"pull,omitempty"`
}
type syncResponse struct {
Cursor int64 `json:"cursor"`
Changes []syncChange `json:"changes"`
ServerTime time.Time `json:"serverTime"`
HasMore bool `json:"hasMore"`
}
func (api *API) sync(w http.ResponseWriter, r *http.Request) {
var request syncRequest
if !decodeJSON(w, r, &request) {
return
}
if request.Cursor < 0 || len(request.Changes) > maxSyncChanges {
writeError(w, http.StatusBadRequest, "invalid_sync_request", "Cursor or change count is invalid")
return
}
for index := range request.Changes {
if err := validateChange(request.Changes[index]); err != nil {
writeError(w, http.StatusBadRequest, "invalid_change", err.Error())
return
}
}
userID := claims(r).Subject
tx, err := api.db.BeginTx(r.Context(), nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not begin synchronization")
return
}
defer tx.Rollback()
for _, change := range request.Changes {
if err := applyChange(r, tx, userID, change); err != nil {
api.logger.Error("apply sync change", "error", err)
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not apply changes")
return
}
}
changes := make([]syncChange, 0)
cursor := request.Cursor
hasMore := false
shouldPull := request.Pull == nil || *request.Pull
if shouldPull {
changes, cursor, hasMore, err = loadChanges(r, tx, userID, request.Cursor)
if err != nil {
api.logger.Error("load sync changes", "error", err)
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not load changes")
return
}
}
if err := tx.Commit(); err != nil {
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not complete synchronization")
return
}
writeJSON(w, http.StatusOK, syncResponse{Cursor: cursor, Changes: changes, ServerTime: time.Now().UTC(), HasMore: hasMore})
}
func validateChange(change syncChange) error {
if change.Entity != "transaction" && change.Entity != "category" && change.Entity != "budget" {
return errors.New("entity must be transaction, category, or budget")
}
if len(change.ID) < 1 || len(change.ID) > 128 {
return errors.New("record id is invalid")
}
if change.UpdatedAt.IsZero() {
return errors.New("updatedAt is required")
}
if change.UpdatedAt.After(time.Now().UTC().Add(24 * time.Hour)) {
return errors.New("updatedAt is too far in the future")
}
if !change.Deleted && (len(change.Payload) == 0 || !json.Valid(change.Payload)) {
return errors.New("payload is required for active records")
}
if len(change.Payload) > 256*1024 {
return errors.New("payload is too large")
}
return nil
}
func applyChange(r *http.Request, tx *sql.Tx, userID string, incoming syncChange) error {
var currentUpdated string
var currentVersion int64
err := tx.QueryRowContext(r.Context(), `SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?`, userID, incoming.Entity, incoming.ID).Scan(&currentUpdated, &currentVersion)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
parsed, parseErr := time.Parse(time.RFC3339Nano, currentUpdated)
if parseErr != nil {
return parseErr
}
if !incoming.UpdatedAt.After(parsed) {
return nil
}
}
version := currentVersion + 1
updated := incoming.UpdatedAt.UTC().Format(time.RFC3339Nano)
var payload any
if !incoming.Deleted {
payload = string(incoming.Payload)
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO records(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)
ON CONFLICT(user_id,entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version`,
userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version)
if err != nil {
return err
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)`, userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version)
return err
}
func loadChanges(r *http.Request, tx *sql.Tx, userID string, after int64) ([]syncChange, int64, bool, error) {
rows, err := tx.QueryContext(r.Context(), `SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM changes WHERE user_id=? AND sequence>? ORDER BY sequence ASC LIMIT ?`, userID, after, syncPageSize+1)
if err != nil {
return nil, after, false, err
}
defer rows.Close()
result := make([]syncChange, 0)
cursor := after
hasMore := false
for rows.Next() {
if len(result) == syncPageSize {
hasMore = true
break
}
var item syncChange
var payload sql.NullString
var updated string
if err := rows.Scan(&item.Sequence, &item.Entity, &item.ID, &payload, &updated, &item.Deleted, &item.Version); err != nil {
return nil, after, false, err
}
item.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated)
if err != nil {
return nil, after, false, err
}
if payload.Valid {
item.Payload = json.RawMessage(payload.String)
}
cursor = item.Sequence
result = append(result, item)
}
return result, cursor, hasMore, rows.Err()
}
-34
View File
@@ -1,34 +0,0 @@
package httpapi
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed web/*
var embeddedWeb embed.FS
var webContent = func() http.Handler {
content, err := fs.Sub(embeddedWeb, "web")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(content))
}()
func (api *API) webApp(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v1/") {
writeError(w, http.StatusNotFound, "not_found", "Endpoint not found")
return
}
cleaned := path.Clean(r.URL.Path)
if cleaned == "." || cleaned == "/" || cleaned == "/index.html" || cleaned == "/service-worker.js" {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
webContent.ServeHTTP(w, r)
}
+3 -10
View File
@@ -114,17 +114,10 @@ mod tests {
let path = directory.join("existing.sqlite"); let path = directory.join("existing.sqlite");
{ {
let db = Connection::open(&path).unwrap(); let db = Connection::open(&path).unwrap();
// Use the original schema, so a divergence between implementations // Use the original Go schema, so a divergence is caught even when
// is caught even when Rust's migration is internally consistent. // Rust's migration is internally consistent.
let go_source = include_str!("../internal/database/database.go"); db.execute_batch(include_str!("../tests/fixtures/go_schema_v1.sql"))
let schema = go_source
.split("const schema = `")
.nth(1)
.unwrap()
.split('`')
.next()
.unwrap(); .unwrap();
db.execute_batch(schema).unwrap();
db.execute_batch( db.execute_batch(
"INSERT INTO users VALUES ('go-user','go@example.com','existing hash','2026-01-01T00:00:00.123456789Z'); "INSERT INTO users VALUES ('go-user','go@example.com','existing hash','2026-01-01T00:00:00.123456789Z');
INSERT INTO refresh_tokens(id,token_hash,user_id,expires_at,created_at) VALUES (7,'existing-token-hash','go-user','2099-01-01T00:00:00Z','2026-01-01T00:00:00Z'); INSERT INTO refresh_tokens(id,token_hash,user_id,expires_at,created_at) VALUES (7,'existing-token-hash','go-user','2099-01-01T00:00:00Z','2026-01-01T00:00:00Z');
+6 -9
View File
@@ -687,27 +687,24 @@ fn web(path: &str, headers: &HeaderMap) -> Response {
} }
let (bytes, mime): (&'static [u8], &'static str) = match cleaned.as_str() { let (bytes, mime): (&'static [u8], &'static str) = match cleaned.as_str() {
"/" => ( "/" => (
include_bytes!("../internal/httpapi/web/index.html"), include_bytes!("../web/index.html"),
"text/html; charset=utf-8", "text/html; charset=utf-8",
), ),
"/app.js" => ( "/app.js" => (
include_bytes!("../internal/httpapi/web/app.js"), include_bytes!("../web/app.js"),
"text/javascript; charset=utf-8", "text/javascript; charset=utf-8",
), ),
"/service-worker.js" => ( "/service-worker.js" => (
include_bytes!("../internal/httpapi/web/service-worker.js"), include_bytes!("../web/service-worker.js"),
"text/javascript; charset=utf-8", "text/javascript; charset=utf-8",
), ),
"/styles.css" => ( "/styles.css" => (
include_bytes!("../internal/httpapi/web/styles.css"), include_bytes!("../web/styles.css"),
"text/css; charset=utf-8", "text/css; charset=utf-8",
), ),
"/icon.svg" => ( "/icon.svg" => (include_bytes!("../web/icon.svg"), "image/svg+xml"),
include_bytes!("../internal/httpapi/web/icon.svg"),
"image/svg+xml",
),
"/manifest.webmanifest" => ( "/manifest.webmanifest" => (
include_bytes!("../internal/httpapi/web/manifest.webmanifest"), include_bytes!("../web/manifest.webmanifest"),
"text/plain; charset=utf-8", "text/plain; charset=utf-8",
), ),
_ => return plain(404, "404 page not found\n"), _ => return plain(404, "404 page not found\n"),
@@ -1,43 +1,5 @@
package database -- SQLite schema created by the original Go server (v1.0.0, internal/database/database.go).
-- Rust must keep opening databases with exactly this schema; do not edit.
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
func Open(path string) (*sql.DB, error) {
directory := filepath.Dir(path)
if directory != "." {
if err := os.MkdirAll(directory, 0o750); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
}
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
if err := migrate(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
func migrate(ctx context.Context, db *sql.DB) error {
const schema = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE, email TEXT NOT NULL UNIQUE COLLATE NOCASE,
@@ -74,7 +36,3 @@ CREATE TABLE IF NOT EXISTS changes (
version INTEGER NOT NULL version INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence); CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);
`
_, err := db.ExecContext(ctx, schema)
return err
}
+51 -28
View File
@@ -1,6 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Black-box Go/Rust compatibility and bilateral SQLite takeover checks (stdlib only).""" """Black-box HTTP/JSON regression checks against the recorded Go reference (stdlib only).
compatibility_expected.txt holds the observable responses of the original Go
server (v1.0.0), which the Apple and Android clients were built against. Any
difference is a client-visible API change. After an intentional change, review
the diff and re-record with --update.
"""
import argparse import argparse
import ast
import base64 import base64
import concurrent.futures import concurrent.futures
import contextlib import contextlib
@@ -9,6 +16,7 @@ import hashlib
import hmac import hmac
import json import json
import os import os
import pprint
from pathlib import Path from pathlib import Path
import socket import socket
import sqlite3 import sqlite3
@@ -17,6 +25,7 @@ import tempfile
import time import time
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
EXPECTED = Path(__file__).with_name('compatibility_expected.txt')
SECRET = 'compatibility-test-secret-not-for-deployment-0123456789' SECRET = 'compatibility-test-secret-not-for-deployment-0123456789'
PASSWORD = 'compatibility password' PASSWORD = 'compatibility password'
@@ -370,38 +379,52 @@ def raw_json_scenarios(binary, database):
return observed return observed
def web_assets(value):
"""Stand in for embedded web files, so the reference survives web client edits."""
if isinstance(value, bytes):
for asset in sorted((ROOT / 'web').iterdir()):
if value == asset.read_bytes():
return ('web asset', asset.name)
return value
if isinstance(value, dict):
return {k: web_assets(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return type(value)(web_assets(v) for v in value)
return value
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--go-binary', type=Path) parser.add_argument('--binary', type=Path, default=ROOT / 'target/debug/balance-server')
parser.add_argument('--rust-binary', type=Path, default=ROOT / 'target/debug/balance-server') parser.add_argument('--update', action='store_true',
help=f'record the responses as the new reference in {EXPECTED.name}')
args = parser.parse_args() args = parser.parse_args()
binary = args.binary.resolve()
assert binary.is_file(), f'Build first: cargo build --locked (missing {binary})'
with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory: with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory:
directory = Path(directory) directory = Path(directory)
go = args.go_binary.resolve() if args.go_binary else directory / 'go-server' actual = {}
if args.go_binary is None: print('Running HTTP/JSON scenarios...', flush=True)
subprocess.run(['go','build','-o',str(go),'./cmd/balance-server'], cwd=ROOT, check=True) actual['scenario'] = scenario(binary, directory / 'scenario.db')
rust = args.rust_binary.resolve() print('Running HTTP/JSON edge cases...', flush=True)
assert rust.is_file(), f'Build Rust first: cargo build --locked (missing {rust})' actual['edge'] = edge_scenarios(binary, directory / 'edge.db')
print('Running Go reference scenarios...', flush=True) print('Running raw JSON payload and Unicode preservation...', flush=True)
expected = scenario(go, directory / 'go.db') actual['raw'] = raw_json_scenarios(binary, directory / 'raw.db')
print('Running Rust scenarios...', flush=True) actual = web_assets(actual)
actual = scenario(rust, directory / 'rust.db') if args.update:
for key in expected: EXPECTED.write_text(pprint.pformat(actual, width=100) + '\n')
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' print(f'Recorded {EXPECTED.relative_to(ROOT)}')
print('Comparing HTTP/JSON edge cases...', flush=True) else:
expected = edge_scenarios(go, directory / 'go-edge.db') expected = ast.literal_eval(EXPECTED.read_text())
actual = edge_scenarios(rust, directory / 'rust-edge.db') for group in expected:
for key in expected: for key in expected[group]:
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' assert actual[group].get(key) == expected[group][key], (
print('Comparing raw JSON payload and Unicode preservation...', flush=True) f'Reference mismatch: {group} {key}\n'
expected = raw_json_scenarios(go, directory / 'go-raw.db') f'Expected: {expected[group][key]!r}\nActual: {actual[group].get(key)!r}')
actual = raw_json_scenarios(rust, directory / 'rust-raw.db') assert actual[group].keys() == expected[group].keys(), f'Scenario set changed in {group}; re-record with --update'
for key in expected: print('Checking SQLite persistence across restarts...', flush=True)
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' takeover(binary, binary, directory / 'restart.db')
for first, second, name in [(go,rust,'go-rust'),(rust,go,'rust-go')]: print('PASS: HTTP/JSON reference scenarios and SQLite persistence')
print(f'Checking {name} SQLite takeover and rollback...', flush=True)
takeover(first, second, directory / f'{name}.db')
print('PASS: HTTP differential scenarios and bilateral SQLite takeover/rollback')
if __name__ == '__main__': if __name__ == '__main__':
+769
View File
@@ -0,0 +1,769 @@
{'edge': {'expired refresh': (401,
{},
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}}),
('DELETE', '/app.js'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/health'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/me'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('HEAD', '/app.js'): (200, {'content-type': 'text/javascript; charset=utf-8'}, b''),
('HEAD', '/health'): (200, {'content-type': 'application/json; charset=utf-8'}, b''),
('HEAD', '/v1/auth/login'): (404,
{'content-type': 'application/json; charset=utf-8'},
b''),
('HEAD', '/v1/me'): (401, {'content-type': 'application/json; charset=utf-8'}, b''),
('HEAD', '/v1/sync'): (404, {'content-type': 'application/json; charset=utf-8'}, b''),
('PATCH', '/app.js'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/health'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/app.js'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/health'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/v1/auth/login'): (400,
{'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('POST', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/v1/sync'): (401,
{'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('PUT', '/app.js'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/health'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('bearer', ''): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('bearer', 'Basic bad'): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is '
'required'}}),
('bearer', 'Bearer '): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('bearer', 'Bearer bad'): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('bearer', 'bearer bad'): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is '
'required'}}),
('login', 0): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 1): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 2): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 3): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('login', 5): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 6): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('logout JSON', 0): (204, {}, b''),
('logout JSON', 1): (204, {}, b''),
('logout JSON', 2): (204, {}, b''),
('logout JSON', 3): (204, {}, b''),
('logout JSON', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('logout JSON', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('password boundary', 0): 201,
('password boundary', 1): 201,
('password boundary', 2): (400,
{},
{'error': {'code': 'invalid_password',
'message': 'password is too long'}}),
('path', '/%2e%2e/app.js'): (200,
{'cache-control': 'public, max-age=3600',
'content-type': 'text/javascript; charset=utf-8'},
('web asset', 'app.js')),
('path', '/%61pp.js'): (200,
{'cache-control': 'public, max-age=3600',
'content-type': 'text/javascript; charset=utf-8'},
('web asset', 'app.js')),
('path', '//app.js'): (307,
{'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/a/../app.js'): (307,
{'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/app.js/'): (301,
{'cache-control': 'public, max-age=3600', 'location': '../app.js'},
b''),
('path', '/index.html'): (301, {'cache-control': 'no-cache', 'location': './'}, b''),
('path', '/v1'): (404,
{'content-type': 'text/plain; charset=utf-8'},
b'404 page not found\n'),
('path', '/v1/'): (404,
{'cache-control': 'no-store',
'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'not_found', 'message': 'Endpoint not found'}}),
('path', '/v1/../app.js'): (307,
{'cache-control': 'no-store',
'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/v1//me'): (307,
{'cache-control': 'no-store',
'content-type': 'text/html; charset=utf-8',
'location': '/v1/me'},
b'<a href="/v1/me">Temporary Redirect</a>.\n\n'),
('range', 'bytes=-10'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 1112-1121/1122',
'content-type': 'image/svg+xml'},
b'/>\n</svg>\n'),
('range', 'bytes=0-0,2-2'): (200,
{'accept-ranges': 'bytes', 'content-type': 'image/svg+xml'},
('web asset', 'icon.svg')),
('range', 'bytes=0-9'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 0-9/1122',
'content-type': 'image/svg+xml'},
b'<svg xmlns'),
('range', 'bytes=10-'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 10-1121/1122',
'content-type': 'image/svg+xml'},
b'="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="im'
b'g" aria-labelledby="title">\n <title id="title">Balance</tit'
b'le>\n <defs>\n <linearGradient id="bg" x1="70" y1="36" x2='
b'"446" y2="476" gradientUnits="userSpaceOnUse">\n <stop s'
b'top-color="#7777ef"/>\n <stop offset="1" stop-color="#42'
b'42b8"/>\n </linearGradient>\n <linearGradient id="shine"'
b' x1="144" y1="122" x2="366" y2="386" gradientUnits="userSpac'
b'eOnUse">\n <stop stop-color="#fff"/>\n <stop offset='
b'"1" stop-color="#dedfff"/>\n </linearGradient>\n </def'
b's>\n <rect x="28" y="28" width="456" height="456" rx="128" f'
b'ill="url(#bg)"/>\n <path d="M141 173c0-25 20-45 45-45h140c25'
b' 0 45 20 45 45v18H186c-25 0-45 20-45 45v-63Z" fill="#fff" op'
b'acity=".5"/>\n <path d="M116 222c0-28 23-51 51-51h179c28 0 5'
b'1 23 51 51v126c0 28-23 51-51 51H167c-28 0-51-23-51-51V222Z" '
b'fill="url(#shine)"/>\n <path d="M302 258h95v86h-95c-24 0-43-'
b'19-43-43s19-43 43-43Z" fill="#5b5bd6"/>\n <circle cx="306" c'
b'y="301" r="13" fill="#fff"/>\n <path d="M184 250h44M184 289h'
b'44M184 328h44" stroke="#5b5bd6" stroke-width="18" stroke-lin'
b'ecap="round" opacity=".72"/>\n</svg>\n'),
('range', 'bytes=10-1'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('range', 'bytes=99999999-'): (416,
{'content-range': 'bytes */1122',
'content-type': 'text/plain; charset=utf-8'},
b'invalid range: failed to overlap\n'),
('range', 'bytes=bad'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('range', 'items=0-1'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('raw sync', 0): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 1): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 2): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 3): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 6): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'entity must be transaction, category, or '
'budget'}}),
('raw sync', 7): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 8): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 9): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 10): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('refresh JSON', 0): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 1): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 2): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 3): (401,
{},
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}}),
('refresh JSON', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('refresh JSON', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('register', 0): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 1): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 2): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 3): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('register', 5): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 6): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('signed claims', 0): (200, {}, {'email': 'e', 'id': 'u'}),
('signed claims', 1): (200, {}, {'email': 'e', 'id': 'u'}),
('signed claims', 2): (200, {}, {'email': '', 'id': 'u'}),
('signed claims', 3): (200, {}, {'email': '', 'id': 'u'}),
('signed claims', 4): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('signed claims', 5): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('signed claims', 6): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('sync validation', 0): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'entity must be transaction, category, or '
'budget'}}),
('sync validation', 1): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'record id is invalid'}}),
('sync validation', 2): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 3): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 4): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'updatedAt is required'}}),
('sync validation', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('sync validation', 6): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 7): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('sync validation', 8): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 9): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 10): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 11): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 12): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'updatedAt is too far in the future'}}),
('sync validation', 13): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'payload is too large'}})},
'raw': {'raw database payloads': [(b'wire-0',
b'{ "duplicate":1, "duplicate":2, "n":123456789012345678901234'
b'567890, "big":1e999 }'),
(b'wire-1',
b'{"lone":"\\ud800","low":"\\udfff","pair":"\\ud83d\\ude00"}'),
(b'wire-2', b'{"invalid":"\xff\xc0\xed\xa0\x80"}'),
(b'wire-3',
b'{"html":"<>&","separator":"\xe2\x80\xa8\xe2\x80\xa9"}'),
(b'wire-4',
b'{"nested":[{"payload":{"id":"inner"}},"quote\\"end",null,true'
b']}'),
(b'\xef\xbf\xbd', b'{}')],
('unicode credentials', 0): (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('unicode credentials', 1): (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('unicode id', 0): {'changes': [{'deleted': False,
'entity': 'category',
'id': '�',
'payload': {},
'sequence': 6,
'updatedAt': '2025-01-02T00:00:00Z',
'version': 1}],
'cursor': 6,
'hasMore': False},
('unicode id', 1): {'changes': [], 'cursor': 6, 'hasMore': False},
('wire payload', 0): b'{"duplicate":1,"duplicate":2,"n":123456789012345678901234567890,'
b'"big":1e999}',
('wire payload', 1): b'{"lone":"\\ud800","low":"\\udfff","pair":"\\ud83d\\ude00"}',
('wire payload', 2): b'{"invalid":"\xff\xc0\xed\xa0\x80"}',
('wire payload', 3): b'{"html":"\\u003c\\u003e\\u0026","separator":"\\u2028\\u2029"}',
('wire payload', 4): b'{"nested":[{"payload":{"id":"inner"}},"quote\\"end",null,true]}'},
'scenario': {'/': (200,
{'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; base-uri 'none'; connect-src "
"'self'; font-src 'self'; form-action 'self'; "
"frame-ancestors 'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; style-src "
"'self'; worker-src 'self'",
'content-type': 'text/html; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'index.html')),
'/app.js': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors 'none'; "
"img-src 'self' data:; manifest-src 'self'; "
"script-src 'self'; style-src 'self'; "
"worker-src 'self'",
'content-type': 'text/javascript; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'app.js')),
'/icon.svg': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; "
"style-src 'self'; worker-src 'self'",
'content-type': 'image/svg+xml',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'icon.svg')),
'/manifest.webmanifest': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri "
"'none'; connect-src 'self'; "
"font-src 'self'; form-action "
"'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; "
"script-src 'self'; style-src "
"'self'; worker-src 'self'",
'content-type': 'text/plain; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), '
'geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'manifest.webmanifest')),
'/missing-file': (404,
{'cache-control': None,
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src "
"'self'; style-src 'self'; worker-src "
"'self'",
'content-type': 'text/plain; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
b'404 page not found\n'),
'/service-worker.js': (200,
{'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; base-uri "
"'none'; connect-src 'self'; "
"font-src 'self'; form-action "
"'self'; frame-ancestors 'none'; "
"img-src 'self' data:; "
"manifest-src 'self'; script-src "
"'self'; style-src 'self'; "
"worker-src 'self'",
'content-type': 'text/javascript; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), '
'geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'service-worker.js')),
'/styles.css': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; "
"style-src 'self'; worker-src 'self'",
'content-type': 'text/css; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'styles.css')),
'GET /v1/me None': (401,
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
'GET /v1/missing None': (404,
{'error': {'code': 'not_found',
'message': 'Endpoint not found'}}),
"POST /v1/auth/login {'email': 'User@Example.COM', 'password': 'compatibility password'}": (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email '
'or '
'password '
'is '
'incorrect'}}),
"POST /v1/auth/login {'unknown': 1}": (400,
{'error': {'code': 'invalid_json',
'message': 'Request body is not '
'valid JSON'}}),
"POST /v1/auth/refresh {'refreshToken': 'bad'}": (401,
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh '
'token is '
'invalid or '
'expired'}}),
'POST /v1/auth/refresh {}': (400,
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
"POST /v1/auth/register {'email': 'a@b', 'password': 'short'}": (400,
{'error': {'code': 'invalid_password',
'message': 'password '
'must '
'contain '
'at '
'least '
'8 '
'characters'}}),
"POST /v1/auth/register {'email': 'bad', 'password': 'compatibility password'}": (400,
{'error': {'code': 'invalid_email',
'message': 'Enter '
'a '
'valid '
'email '
'address'}}),
'deleted': {'changes': [{'deleted': True,
'entity': 'transaction',
'id': 'one',
'sequence': 5,
'updatedAt': '2025-01-02T00:00:00Z',
'version': 2}],
'cursor': 5,
'hasMore': False},
'duplicate': {'error': {'code': 'email_exists',
'message': 'An account with this email already exists'}},
'expired access': {'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}},
'first': {'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'one',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'category',
'id': 'cat',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 3,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'budget',
'id': 'budget',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 4,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 4,
'hasMore': False},
'https://allowed.example': (204,
{'access-control-allow-headers': 'Authorization, '
'Content-Type',
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-origin': 'https://allowed.example',
'vary': 'Origin'}),
'https://denied.example': (204, {}),
'idempotent': {'changes': [], 'cursor': 4, 'hasMore': False},
'invalid bearer': {'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}},
"invalid sync {'changes': [{'entity': 'category', 'id': 'x'}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'updatedAt '
'is '
'required'}}),
"invalid sync {'changes': [{'entity': 'transaction', 'id': '', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': {'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'record '
'id '
'is '
'invalid'}}),
"invalid sync {'changes': [{'entity': 'transaction', 'id': 'x', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': None}]}": (200,
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
"invalid sync {'changes': [{'entity': 'unknown', 'id': 'x', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': {'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'entity '
'must '
'be '
'transaction, '
'category, '
'or '
'budget'}}),
"invalid sync {'cursor': -1}": (400,
{'error': {'code': 'invalid_sync_request',
'message': 'Cursor or change count is '
'invalid'}}),
"invalid sync {'extra': 1}": (400,
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid '
'JSON'}}),
'logout refresh': {'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}},
'malformed': {'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}},
'pagination': (505, 510),
'rate limit': {'error': {'code': 'rate_limited',
'message': 'Too many authentication requests'}},
'refresh replay': {'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}},
'registration disabled': {'error': {'code': 'registration_disabled',
'message': 'Registration is disabled'}}}}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.