Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f97cdf0f2e | ||
|
|
10272f3851 | ||
|
|
16dd2a3b3c | ||
|
|
2b34c2e318 |
No files matched your search
@@ -9,10 +9,6 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Pin the toolchain to the declared MSRV so CI matches production.
|
||||
RUST_TOOLCHAIN: 1.90.0
|
||||
# The differential check compares Rust against net/http behaviour, which
|
||||
# changes between Go releases (e.g. ServeMux redirects 301 -> 307), so the
|
||||
# reference must be built with the same Go the Rust port mirrors.
|
||||
GO_VERSION: "1.27.3"
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
@@ -58,39 +54,10 @@ jobs:
|
||||
path: target/release/balance-server
|
||||
if-no-files-found: error
|
||||
|
||||
go:
|
||||
name: Go (test / vet / build)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
compatibility:
|
||||
name: Go ↔ Rust differential + SQLite takeover
|
||||
name: HTTP/JSON reference + SQLite persistence
|
||||
runs-on: ubuntu-latest
|
||||
needs: [rust, go]
|
||||
needs: [rust]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -100,11 +67,6 @@ jobs:
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -121,10 +83,8 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Build Rust (debug) and Go reference
|
||||
run: |
|
||||
cargo build --locked
|
||||
go build -o target/integration/go-server ./cmd/balance-server
|
||||
- name: Build Rust (debug)
|
||||
run: cargo build --locked
|
||||
|
||||
- name: Run differential compatibility scenarios
|
||||
run: python3 tools/compatibility.py --rust-binary target/debug/balance-server
|
||||
- name: Compare against the recorded Go reference
|
||||
run: python3 tools/compatibility.py --binary target/debug/balance-server
|
||||
@@ -8,28 +8,14 @@
|
||||
|
||||
## Быстрая карта проекта
|
||||
|
||||
Production-сервер написан на Rust 1.90+. Go 1.25.0+ сохранён как
|
||||
эталон совместимости API/SQLite и вариант отката.
|
||||
Сервер написан на Rust 1.90+. Исходная Go-реализация удалена после v1.0.0
|
||||
(её исходники остались в теге `v1.0.0`), но её поведение остаётся эталоном API:
|
||||
ответы Go записаны в `tools/compatibility_expected.txt`, а исходная SQLite-схема
|
||||
Go — в `tests/fixtures/go_schema_v1.sql`. Оба файла не правь вручную.
|
||||
|
||||
Основные Rust-модули: `src/main.rs`, `runtime.rs`, `config.rs`,
|
||||
`database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`.
|
||||
Веб-клиент из `internal/httpapi/web` встроен в Rust executable.
|
||||
|
||||
Основные Go-файлы:
|
||||
|
||||
```text
|
||||
- internal/database/database.go
|
||||
- internal/config/config.go
|
||||
- internal/auth/tokens.go
|
||||
- internal/auth/password.go
|
||||
- internal/httpapi/auth_handlers.go
|
||||
- internal/httpapi/server.go
|
||||
- internal/httpapi/sync_handler.go
|
||||
- internal/httpapi/web.go
|
||||
- internal/httpapi/server_test.go
|
||||
```
|
||||
|
||||
Количество Go-файлов: **9**.
|
||||
Веб-клиент из `web/` встроен в Rust executable.
|
||||
|
||||
Обнаруженные технологии/интеграции: SQLite, Docker.
|
||||
|
||||
@@ -106,12 +92,13 @@ cargo fmt --check
|
||||
cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo build --locked --release
|
||||
python3 tools/compatibility.py --rust-binary target/release/balance-server
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
python3 tools/compatibility.py --binary target/release/balance-server
|
||||
```
|
||||
|
||||
Если `tools/compatibility.py` нашёл расхождение, это изменение видно клиентам.
|
||||
Перезаписывай эталон (`--update`) только для намеренного изменения API и
|
||||
после обновления документации.
|
||||
|
||||
Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI.
|
||||
|
||||
### 8. Документация
|
||||
@@ -130,8 +117,6 @@ go build ./...
|
||||
- [ ] Sync остаётся идемпотентным.
|
||||
- [ ] DB migration учтена.
|
||||
- [ ] Concurrent requests безопасны.
|
||||
- [ ] `go test ./...` проходит.
|
||||
- [ ] Rust tests, fmt, clippy и release build проходят.
|
||||
- [ ] Differential HTTP/JSON и Go ↔ Rust SQLite takeover проходят.
|
||||
- [ ] `go vet ./...` проходит.
|
||||
- [ ] `tools/compatibility.py` совпадает с записанным эталоном Go.
|
||||
- [ ] Документация обновлена.
|
||||
+1
-1
@@ -23,7 +23,7 @@ RUN set -eu; \
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
COPY internal/httpapi/web ./internal/httpapi/web
|
||||
COPY web ./web
|
||||
RUN . /cross.env \
|
||||
&& cargo build --locked --release --target "$RUST_TARGET" \
|
||||
&& cp "target/$RUST_TARGET/release/balance-server" /balance-server
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Balance Server
|
||||
|
||||

|
||||
|
||||
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
|
||||
@@ -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 bundled SQLite. `cargo build --locked --release --target <target>` requires the
|
||||
matching linker and C compiler. The retained Go implementation is a compatibility
|
||||
reference and rollback option: `go build -o balance-server-go ./cmd/balance-server`.
|
||||
matching linker and C compiler.
|
||||
|
||||
After starting the binary, open `http://localhost:8080` in a browser. The web assets are embedded into the executable, so there is no separate frontend build, Node.js process or static directory to deploy.
|
||||
|
||||
@@ -112,9 +113,10 @@ The sync endpoint accepts transactions, custom categories and monthly budgets. T
|
||||
|
||||
## Compatibility and migration
|
||||
|
||||
The Rust server retains the HTTP/JSON API, embedded web client, environment
|
||||
variables, SQLite schema, Argon2id password hashes, JWT format, refresh sessions
|
||||
and sync cursor semantics of the Go implementation. Docker Compose still maps
|
||||
The Rust server replaced the original Go implementation and retains its HTTP/JSON
|
||||
API, embedded web client, environment variables, SQLite schema, Argon2id password
|
||||
hashes, JWT format, refresh sessions and sync cursor semantics. The Go sources were
|
||||
removed after v1.0.0; that tag still contains them for reference or rollback. Docker Compose still maps
|
||||
host port **8123** to container port **8080** and keeps `/data` in `balance-data`.
|
||||
Direct binary execution defaults to port 8080.
|
||||
|
||||
@@ -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.
|
||||
See [deployment and rollback](docs/DEPLOYMENT.md) for the full procedure.
|
||||
|
||||
Verify the implementations locally with Go 1.27.3 (the version CI uses for the
|
||||
differential check), Rust and Python 3:
|
||||
Verify the server locally with Rust and Python 3:
|
||||
|
||||
```sh
|
||||
cargo test --locked
|
||||
@@ -133,12 +134,11 @@ cargo fmt --check
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo build --locked
|
||||
python3 tools/compatibility.py
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
The Python standard-library suite builds the Go reference, starts both binaries
|
||||
on temporary loopback ports and databases, compares API responses and assets,
|
||||
and tests SQLite takeover and rollback in both directions. To use release or
|
||||
prebuilt executables, pass `--rust-binary PATH` and optionally `--go-binary PATH`.
|
||||
The Python standard-library suite starts the server on temporary loopback ports
|
||||
and databases, compares its API responses and assets with the responses recorded
|
||||
from the original Go server (`tools/compatibility_expected.txt`), and checks that
|
||||
SQLite data survives restarts. To test a release or prebuilt executable, pass
|
||||
`--binary PATH`. After an intentional API change, review the difference and
|
||||
re-record the reference with `--update`.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,16 +23,9 @@ Android ───────────┘
|
||||
|
||||
## Исходный код
|
||||
|
||||
Production runtime — один Rust-процесс: `src/main.rs`, `runtime.rs`, `config.rs`, `database.rs`, `auth.rs`, `sync.rs`, `server.rs`, `go_json.rs`. SQLite bundled через rusqlite; HTTP обслуживается axum/hyper. `runtime.rs` сохраняет socket deadlines Go (header 5s, read 15s, write 30s, idle 60s) и ограничивает graceful shutdown десятью секундами. `go_json.rs` сохраняет особенности encoding/json и исходные payload bytes. Статические файлы из `internal/httpapi/web` встроены при компиляции. Go-код сохранён как эталон совместимости и вариант rollback.
|
||||
Production runtime — один Rust-процесс: `src/main.rs`, `runtime.rs`, `config.rs`, `database.rs`, `auth.rs`, `sync.rs`, `server.rs`, `go_json.rs`. SQLite bundled через rusqlite; HTTP обслуживается axum/hyper. `runtime.rs` сохраняет socket deadlines Go (header 5s, read 15s, write 30s, idle 60s) и ограничивает graceful shutdown десятью секундами. `go_json.rs` сохраняет особенности encoding/json и исходные payload bytes. Статические файлы из `web/` встроены при компиляции.
|
||||
|
||||
Пути сохранённой Go-реализации:
|
||||
|
||||
```text
|
||||
- internal/auth
|
||||
- internal/config
|
||||
- internal/database
|
||||
- internal/httpapi
|
||||
```
|
||||
Исходная Go-реализация удалена после v1.0.0 (исходники остались в теге `v1.0.0`). Её наблюдаемое поведение зафиксировано в `tools/compatibility_expected.txt`, а SQLite-схема — в `tests/fixtures/go_schema_v1.sql`; Rust сверяется с обоими в CI.
|
||||
|
||||
Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API.
|
||||
|
||||
|
||||
+5
-20
@@ -2,27 +2,12 @@
|
||||
|
||||
Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ.
|
||||
|
||||
## Найденные определения моделей/schema
|
||||
## Где определена schema
|
||||
|
||||
- internal/database/database.go
|
||||
- internal/database/database.go
|
||||
- internal/database/database.go
|
||||
- internal/config/config.go
|
||||
- internal/auth/tokens.go
|
||||
- internal/auth/tokens.go
|
||||
- internal/httpapi/auth_handlers.go
|
||||
- internal/httpapi/auth_handlers.go
|
||||
- internal/httpapi/auth_handlers.go
|
||||
- internal/httpapi/server.go
|
||||
- internal/httpapi/server.go
|
||||
- internal/httpapi/server.go
|
||||
- internal/httpapi/sync_handler.go
|
||||
- internal/httpapi/sync_handler.go
|
||||
- internal/httpapi/sync_handler.go
|
||||
- internal/httpapi/web.go
|
||||
- internal/httpapi/server_test.go
|
||||
- internal/httpapi/server_test.go
|
||||
- internal/httpapi/server_test.go
|
||||
- `src/database.rs` — создание и открытие схемы SQLite;
|
||||
- `tests/fixtures/go_schema_v1.sql` — исходная схема Go-сервера v1.0.0; Rust обязан открывать такие базы без изменений;
|
||||
- `src/sync.rs` — валидация и сериализация синхронизируемых записей;
|
||||
- `src/auth.rs` — пользователи, пароли и refresh-сессии.
|
||||
|
||||
## Общие сущности
|
||||
|
||||
|
||||
+11
-11
@@ -3,9 +3,9 @@
|
||||
## Runtime and configuration
|
||||
|
||||
The production executable is the Rust server, built with `cargo build --locked
|
||||
--release`. SQLite is bundled into the executable. The Go implementation remains
|
||||
in `cmd/balance-server` and `internal/` as a compatibility reference and rollback
|
||||
option; build it with `go build -o balance-server-go ./cmd/balance-server`.
|
||||
--release`. SQLite is bundled into the executable. The original Go implementation
|
||||
was removed after v1.0.0; the `v1.0.0` tag still contains its sources for the
|
||||
migration and rollback below (`go build -o balance-server-go ./cmd/balance-server`).
|
||||
|
||||
The Dockerfile uses a Rust 1.90 Debian Bookworm builder and a Debian Bookworm slim
|
||||
runtime, running as the unprivileged `balance` user. The image embeds the existing
|
||||
@@ -65,8 +65,9 @@ required. Do not start both implementations against the same database at once.
|
||||
Stop Rust first. Start the retained Go executable/image with the same current
|
||||
database directory and secret. Rust-written accounts, password hashes, refresh
|
||||
sessions, records, tombstones and cursors remain readable by Go. Check health,
|
||||
login, refresh and sync again. The compatibility suite exercises both directions,
|
||||
including a second takeover back to the original implementation.
|
||||
login, refresh and sync again. Before the Go sources were removed, the
|
||||
compatibility suite verified takeover and rollback in both directions; Rust still
|
||||
writes the same schema (`tests/fixtures/go_schema_v1.sql`).
|
||||
|
||||
For database corruption or an unrelated failed migration, stop all writers before
|
||||
restoring the complete backup directory. Restoring an older backup discards
|
||||
@@ -77,12 +78,11 @@ an active database or delete a Compose volume as part of an upgrade.
|
||||
## Validation and operations
|
||||
|
||||
Before release run `cargo test --locked`, `cargo build --locked --release`,
|
||||
`python3 tools/compatibility.py --rust-binary target/release/balance-server`, and
|
||||
the retained reference checks `go test ./...`, `go vet ./...`, `go build ./...`.
|
||||
The differential suite uses temporary databases and loopback listeners, compares
|
||||
API/errors/security headers/assets, tests browser cookies, pagination, LWW,
|
||||
tombstones, concurrent pushes, token expiration, refresh rotation, rate limiting,
|
||||
and shared-database takeover/rollback in both directions.
|
||||
and `python3 tools/compatibility.py --binary target/release/balance-server`.
|
||||
The suite uses temporary databases and loopback listeners, compares
|
||||
API/errors/security headers/assets with the recorded Go reference, tests browser
|
||||
cookies, pagination, LWW, tombstones, concurrent pushes, token expiration, refresh
|
||||
rotation, rate limiting, and SQLite persistence across restarts.
|
||||
|
||||
Keep regular encrypted backups with retention and restore drills. Monitor HTTP
|
||||
5xx, sync errors, authentication failures, latency, database/change-log growth
|
||||
|
||||
+8
-9
@@ -2,28 +2,26 @@
|
||||
|
||||
## Требования
|
||||
|
||||
Production-сервер использует Rust 1.90+ и bundled SQLite. Go 1.25+ нужен для сохранённой эталонной реализации; differential-проверки (`tools/compatibility.py`) требуют Go 1.27.3, как в CI: поведение `net/http` (например, код редиректа ServeMux 301/307) зависит от версии Go. Python 3 используется без сторонних пакетов.
|
||||
Сервер использует Rust 1.90+ и bundled SQLite. Python 3 нужен для `tools/compatibility.py` и используется без сторонних пакетов. Go больше не нужен.
|
||||
|
||||
Проверить локальную версию:
|
||||
|
||||
```bash
|
||||
rustc --version
|
||||
cargo --version
|
||||
go version
|
||||
python3 --version
|
||||
```
|
||||
|
||||
## Установка зависимостей
|
||||
|
||||
```bash
|
||||
cargo fetch --locked
|
||||
go mod download
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
cargo build --locked
|
||||
go build ./...
|
||||
```
|
||||
|
||||
## Тесты
|
||||
@@ -32,13 +30,15 @@ go build ./...
|
||||
cargo test --locked
|
||||
cargo build --locked
|
||||
python3 tools/compatibility.py
|
||||
go test ./...
|
||||
```
|
||||
|
||||
С race detector:
|
||||
`tools/compatibility.py` запускает сервер на временных портах и базах и сравнивает
|
||||
ответы API и статику с ответами исходного Go-сервера, записанными в
|
||||
`tools/compatibility_expected.txt`. Расхождение означает изменение, видимое
|
||||
клиентам. Если изменение намеренное, проверьте diff и перезапишите эталон:
|
||||
|
||||
```bash
|
||||
go test -race ./...
|
||||
python3 tools/compatibility.py --update
|
||||
```
|
||||
|
||||
## Static checks
|
||||
@@ -46,7 +46,6 @@ go test -race ./...
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline.
|
||||
@@ -89,7 +88,7 @@ cargo clippy --locked --all-targets -- -D warnings
|
||||
↓
|
||||
cargo build --locked --release
|
||||
↓
|
||||
python3 tools/compatibility.py --rust-binary target/release/balance-server
|
||||
python3 tools/compatibility.py --binary target/release/balance-server
|
||||
↓
|
||||
container/package
|
||||
↓
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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 |
@@ -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 & 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 |
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
@@ -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 & 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 |
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxSyncChanges = 5000
|
||||
const syncPageSize = 500
|
||||
|
||||
type syncChange struct {
|
||||
Entity string `json:"entity"`
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
Version int64 `json:"version,omitempty"`
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
}
|
||||
type syncRequest struct {
|
||||
Cursor int64 `json:"cursor"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Changes []syncChange `json:"changes"`
|
||||
Pull *bool `json:"pull,omitempty"`
|
||||
}
|
||||
type syncResponse struct {
|
||||
Cursor int64 `json:"cursor"`
|
||||
Changes []syncChange `json:"changes"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
func (api *API) sync(w http.ResponseWriter, r *http.Request) {
|
||||
var request syncRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if request.Cursor < 0 || len(request.Changes) > maxSyncChanges {
|
||||
writeError(w, http.StatusBadRequest, "invalid_sync_request", "Cursor or change count is invalid")
|
||||
return
|
||||
}
|
||||
for index := range request.Changes {
|
||||
if err := validateChange(request.Changes[index]); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_change", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
userID := claims(r).Subject
|
||||
tx, err := api.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not begin synchronization")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, change := range request.Changes {
|
||||
if err := applyChange(r, tx, userID, change); err != nil {
|
||||
api.logger.Error("apply sync change", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not apply changes")
|
||||
return
|
||||
}
|
||||
}
|
||||
changes := make([]syncChange, 0)
|
||||
cursor := request.Cursor
|
||||
hasMore := false
|
||||
shouldPull := request.Pull == nil || *request.Pull
|
||||
if shouldPull {
|
||||
changes, cursor, hasMore, err = loadChanges(r, tx, userID, request.Cursor)
|
||||
if err != nil {
|
||||
api.logger.Error("load sync changes", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not load changes")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not complete synchronization")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, syncResponse{Cursor: cursor, Changes: changes, ServerTime: time.Now().UTC(), HasMore: hasMore})
|
||||
}
|
||||
|
||||
func validateChange(change syncChange) error {
|
||||
if change.Entity != "transaction" && change.Entity != "category" && change.Entity != "budget" {
|
||||
return errors.New("entity must be transaction, category, or budget")
|
||||
}
|
||||
if len(change.ID) < 1 || len(change.ID) > 128 {
|
||||
return errors.New("record id is invalid")
|
||||
}
|
||||
if change.UpdatedAt.IsZero() {
|
||||
return errors.New("updatedAt is required")
|
||||
}
|
||||
if change.UpdatedAt.After(time.Now().UTC().Add(24 * time.Hour)) {
|
||||
return errors.New("updatedAt is too far in the future")
|
||||
}
|
||||
if !change.Deleted && (len(change.Payload) == 0 || !json.Valid(change.Payload)) {
|
||||
return errors.New("payload is required for active records")
|
||||
}
|
||||
if len(change.Payload) > 256*1024 {
|
||||
return errors.New("payload is too large")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyChange(r *http.Request, tx *sql.Tx, userID string, incoming syncChange) error {
|
||||
var currentUpdated string
|
||||
var currentVersion int64
|
||||
err := tx.QueryRowContext(r.Context(), `SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?`, userID, incoming.Entity, incoming.ID).Scan(¤tUpdated, ¤tVersion)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
parsed, parseErr := time.Parse(time.RFC3339Nano, currentUpdated)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
if !incoming.UpdatedAt.After(parsed) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
version := currentVersion + 1
|
||||
updated := incoming.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
var payload any
|
||||
if !incoming.Deleted {
|
||||
payload = string(incoming.Payload)
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO records(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id,entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version`,
|
||||
userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)`, userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadChanges(r *http.Request, tx *sql.Tx, userID string, after int64) ([]syncChange, int64, bool, error) {
|
||||
rows, err := tx.QueryContext(r.Context(), `SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM changes WHERE user_id=? AND sequence>? ORDER BY sequence ASC LIMIT ?`, userID, after, syncPageSize+1)
|
||||
if err != nil {
|
||||
return nil, after, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]syncChange, 0)
|
||||
cursor := after
|
||||
hasMore := false
|
||||
for rows.Next() {
|
||||
if len(result) == syncPageSize {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
var item syncChange
|
||||
var payload sql.NullString
|
||||
var updated string
|
||||
if err := rows.Scan(&item.Sequence, &item.Entity, &item.ID, &payload, &updated, &item.Deleted, &item.Version); err != nil {
|
||||
return nil, after, false, err
|
||||
}
|
||||
item.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated)
|
||||
if err != nil {
|
||||
return nil, after, false, err
|
||||
}
|
||||
if payload.Valid {
|
||||
item.Payload = json.RawMessage(payload.String)
|
||||
}
|
||||
cursor = item.Sequence
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, cursor, hasMore, rows.Err()
|
||||
}
|
||||
@@ -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
@@ -114,17 +114,10 @@ mod tests {
|
||||
let path = directory.join("existing.sqlite");
|
||||
{
|
||||
let db = Connection::open(&path).unwrap();
|
||||
// Use the original schema, so a divergence between implementations
|
||||
// is caught even when Rust's migration is internally consistent.
|
||||
let go_source = include_str!("../internal/database/database.go");
|
||||
let schema = go_source
|
||||
.split("const schema = `")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('`')
|
||||
.next()
|
||||
// Use the original Go schema, so a divergence is caught even when
|
||||
// Rust's migration is internally consistent.
|
||||
db.execute_batch(include_str!("../tests/fixtures/go_schema_v1.sql"))
|
||||
.unwrap();
|
||||
db.execute_batch(schema).unwrap();
|
||||
db.execute_batch(
|
||||
"INSERT INTO users VALUES ('go-user','go@example.com','existing hash','2026-01-01T00:00:00.123456789Z');
|
||||
INSERT INTO refresh_tokens(id,token_hash,user_id,expires_at,created_at) VALUES (7,'existing-token-hash','go-user','2099-01-01T00:00:00Z','2026-01-01T00:00:00Z');
|
||||
|
||||
+6
-9
@@ -687,27 +687,24 @@ fn web(path: &str, headers: &HeaderMap) -> Response {
|
||||
}
|
||||
let (bytes, mime): (&'static [u8], &'static str) = match cleaned.as_str() {
|
||||
"/" => (
|
||||
include_bytes!("../internal/httpapi/web/index.html"),
|
||||
include_bytes!("../web/index.html"),
|
||||
"text/html; charset=utf-8",
|
||||
),
|
||||
"/app.js" => (
|
||||
include_bytes!("../internal/httpapi/web/app.js"),
|
||||
include_bytes!("../web/app.js"),
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"/service-worker.js" => (
|
||||
include_bytes!("../internal/httpapi/web/service-worker.js"),
|
||||
include_bytes!("../web/service-worker.js"),
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"/styles.css" => (
|
||||
include_bytes!("../internal/httpapi/web/styles.css"),
|
||||
include_bytes!("../web/styles.css"),
|
||||
"text/css; charset=utf-8",
|
||||
),
|
||||
"/icon.svg" => (
|
||||
include_bytes!("../internal/httpapi/web/icon.svg"),
|
||||
"image/svg+xml",
|
||||
),
|
||||
"/icon.svg" => (include_bytes!("../web/icon.svg"), "image/svg+xml"),
|
||||
"/manifest.webmanifest" => (
|
||||
include_bytes!("../internal/httpapi/web/manifest.webmanifest"),
|
||||
include_bytes!("../web/manifest.webmanifest"),
|
||||
"text/plain; charset=utf-8",
|
||||
),
|
||||
_ => return plain(404, "404 page not found\n"),
|
||||
|
||||
@@ -1,43 +1,5 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
directory := filepath.Dir(path)
|
||||
if directory != "." {
|
||||
if err := os.MkdirAll(directory, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create database directory: %w", err)
|
||||
}
|
||||
}
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := migrate(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, db *sql.DB) error {
|
||||
const schema = `
|
||||
-- SQLite schema created by the original Go server (v1.0.0, internal/database/database.go).
|
||||
-- Rust must keep opening databases with exactly this schema; do not edit.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
@@ -74,7 +36,3 @@ CREATE TABLE IF NOT EXISTS changes (
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);
|
||||
`
|
||||
_, err := db.ExecContext(ctx, schema)
|
||||
return err
|
||||
}
|
||||
+51
-28
@@ -1,6 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Black-box Go/Rust compatibility and bilateral SQLite takeover checks (stdlib only)."""
|
||||
"""Black-box HTTP/JSON regression checks against the recorded Go reference (stdlib only).
|
||||
|
||||
compatibility_expected.txt holds the observable responses of the original Go
|
||||
server (v1.0.0), which the Apple and Android clients were built against. Any
|
||||
difference is a client-visible API change. After an intentional change, review
|
||||
the diff and re-record with --update.
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
@@ -9,6 +16,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import pprint
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sqlite3
|
||||
@@ -17,6 +25,7 @@ import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EXPECTED = Path(__file__).with_name('compatibility_expected.txt')
|
||||
SECRET = 'compatibility-test-secret-not-for-deployment-0123456789'
|
||||
PASSWORD = 'compatibility password'
|
||||
|
||||
@@ -370,38 +379,52 @@ def raw_json_scenarios(binary, database):
|
||||
return observed
|
||||
|
||||
|
||||
def web_assets(value):
|
||||
"""Stand in for embedded web files, so the reference survives web client edits."""
|
||||
if isinstance(value, bytes):
|
||||
for asset in sorted((ROOT / 'web').iterdir()):
|
||||
if value == asset.read_bytes():
|
||||
return ('web asset', asset.name)
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {k: web_assets(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return type(value)(web_assets(v) for v in value)
|
||||
return value
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--go-binary', type=Path)
|
||||
parser.add_argument('--rust-binary', type=Path, default=ROOT / 'target/debug/balance-server')
|
||||
parser.add_argument('--binary', type=Path, default=ROOT / 'target/debug/balance-server')
|
||||
parser.add_argument('--update', action='store_true',
|
||||
help=f'record the responses as the new reference in {EXPECTED.name}')
|
||||
args = parser.parse_args()
|
||||
binary = args.binary.resolve()
|
||||
assert binary.is_file(), f'Build first: cargo build --locked (missing {binary})'
|
||||
with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory:
|
||||
directory = Path(directory)
|
||||
go = args.go_binary.resolve() if args.go_binary else directory / 'go-server'
|
||||
if args.go_binary is None:
|
||||
subprocess.run(['go','build','-o',str(go),'./cmd/balance-server'], cwd=ROOT, check=True)
|
||||
rust = args.rust_binary.resolve()
|
||||
assert rust.is_file(), f'Build Rust first: cargo build --locked (missing {rust})'
|
||||
print('Running Go reference scenarios...', flush=True)
|
||||
expected = scenario(go, directory / 'go.db')
|
||||
print('Running Rust scenarios...', flush=True)
|
||||
actual = scenario(rust, directory / 'rust.db')
|
||||
for key in expected:
|
||||
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
|
||||
print('Comparing HTTP/JSON edge cases...', flush=True)
|
||||
expected = edge_scenarios(go, directory / 'go-edge.db')
|
||||
actual = edge_scenarios(rust, directory / 'rust-edge.db')
|
||||
for key in expected:
|
||||
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
|
||||
print('Comparing raw JSON payload and Unicode preservation...', flush=True)
|
||||
expected = raw_json_scenarios(go, directory / 'go-raw.db')
|
||||
actual = raw_json_scenarios(rust, directory / 'rust-raw.db')
|
||||
for key in expected:
|
||||
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
|
||||
for first, second, name in [(go,rust,'go-rust'),(rust,go,'rust-go')]:
|
||||
print(f'Checking {name} SQLite takeover and rollback...', flush=True)
|
||||
takeover(first, second, directory / f'{name}.db')
|
||||
print('PASS: HTTP differential scenarios and bilateral SQLite takeover/rollback')
|
||||
actual = {}
|
||||
print('Running HTTP/JSON scenarios...', flush=True)
|
||||
actual['scenario'] = scenario(binary, directory / 'scenario.db')
|
||||
print('Running HTTP/JSON edge cases...', flush=True)
|
||||
actual['edge'] = edge_scenarios(binary, directory / 'edge.db')
|
||||
print('Running raw JSON payload and Unicode preservation...', flush=True)
|
||||
actual['raw'] = raw_json_scenarios(binary, directory / 'raw.db')
|
||||
actual = web_assets(actual)
|
||||
if args.update:
|
||||
EXPECTED.write_text(pprint.pformat(actual, width=100) + '\n')
|
||||
print(f'Recorded {EXPECTED.relative_to(ROOT)}')
|
||||
else:
|
||||
expected = ast.literal_eval(EXPECTED.read_text())
|
||||
for group in expected:
|
||||
for key in expected[group]:
|
||||
assert actual[group].get(key) == expected[group][key], (
|
||||
f'Reference mismatch: {group} {key}\n'
|
||||
f'Expected: {expected[group][key]!r}\nActual: {actual[group].get(key)!r}')
|
||||
assert actual[group].keys() == expected[group].keys(), f'Scenario set changed in {group}; re-record with --update'
|
||||
print('Checking SQLite persistence across restarts...', flush=True)
|
||||
takeover(binary, binary, directory / 'restart.db')
|
||||
print('PASS: HTTP/JSON reference scenarios and SQLite persistence')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -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.
Reference in new issue
Block a user