diff --git a/.gitea/workflows/integration.yaml b/.gitea/workflows/integration.yaml new file mode 100644 index 0000000..dde47b6 --- /dev/null +++ b/.gitea/workflows/integration.yaml @@ -0,0 +1,50 @@ +name: Tests · Integration + +# Postgres-backed integration tests for the Go backend, gated behind the +# `integration` build tag. They spin a throwaway postgres:17-alpine container via +# testcontainers-go, which reaches the host Docker daemon through the socket the +# Gitea runner exposes. Slower than the unit job (go-unit.yaml); run serially +# (-p=1) with Ryuk disabled — TestMain terminates its own container. The module +# list grows as new go.work modules are added by later stages. + +on: + push: + paths: + - 'backend/**' + - 'go.work' + - 'go.work.sum' + - '.gitea/workflows/integration.yaml' + - '!**/*.md' + pull_request: + paths: + - 'backend/**' + - 'go.work' + - 'go.work.sum' + - '.gitea/workflows/integration.yaml' + - '!**/*.md' + +jobs: + integration: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + env: + # Ryuk (testcontainers' reaper) does not start cleanly on every runner; + # the suite's TestMain terminates its own container, so disable it. + TESTCONTAINERS_RYUK_DISABLED: "true" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + cache: true + + - name: Integration tests + # -count=1 disables the test cache; -p=1 -parallel=1 keeps the + # container-backed tests serial; the 15-minute timeout bounds a stuck + # container pull. + run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/... diff --git a/CLAUDE.md b/CLAUDE.md index a4c024f..89f2068 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,8 @@ conversation memory — is the source of continuity. Keep it that way. ## Stack Go 1.26.3, `go.work` monorepo, module paths `scrabble/`. Dependencies are -added **when first used** (incremental): backend currently uses only `gin` + -`zap`; pgx/goose/jet/OTel arrive with Stage 1+. Client↔gateway is Connect-RPC + +added **when first used** (incremental): backend uses `gin` + `zap` + +`pgx`/`go-jet`/`goose`/OTel (added in Stage 1). Client↔gateway is Connect-RPC + FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite, packaged to native with Capacitor. Likely no Redis. @@ -100,9 +100,15 @@ Constraints: ``` go.work # use the existing modules; grows per stage backend/ # module scrabble/backend - cmd/backend/ # main: boots HTTP listener - internal/config/ # env config - internal/server/ # gin engine, /healthz, /readyz, lifecycle + cmd/backend/ # main: telemetry -> db+migrate -> cache -> server + cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container) + internal/config/ # env config (composes postgres + telemetry) + internal/telemetry/ # OTel providers + request-timing middleware + internal/postgres/ # pgx/database-sql pool, goose migrations/, jet/ (generated) + internal/account/ # durable accounts + identities (store) + internal/session/ # opaque tokens, sessions store, cache, service + internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes + internal/inttest/ # //go:build integration Postgres-backed tests docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md gateway/ ui/ pkg/ platform/ # added by their stages ``` diff --git a/PLAN.md b/PLAN.md index 60d8353..bae3096 100644 --- a/PLAN.md +++ b/PLAN.md @@ -150,3 +150,26 @@ Open details: deployment target/host; dashboards; load expectations. compose deferred to a stage that has something to deploy. Trunk is `master` (owner preference); `feature/*` + PR from Stage 1; the genesis commit lands on `master` by necessity. +- **Stage 1** (interview + implementation): + - Query layer: **go-jet** over `database/sql` (pgx stdlib) + otelsql; a + `cmd/jetgen` tool regenerates the **committed** code from a throwaway + container. Postgres **17** pinned for jetgen, tests and prod. + - Sessions: opaque token stored only as a **SHA-256 hash** (kept as hex + `text`, not `bytea` — avoids jet bytea-literal friction), **revoke-only** + (no TTL); revocation-audit table deferred. Backend keeps a warmed + write-through session cache that gates `/readyz`. + - Data model: **UUIDv7** PKs; one unified `identities` table + (`kind ∈ telegram|email`, widen to `vk`/`max` later); no soft-delete / + actor-audit columns yet. + - HTTP surface: **service/store/cache layer only**. `/api/v1/{public,user, + internal,admin}` groups + `X-User-ID` middleware are scaffolding (exposed via + `Server` group accessors); the session/account REST handlers land with the + gateway in **Stage 6**. Admin bootstrap deferred to **Stage 9**. + - Telemetry: providers + request-timing middleware + otelsql; exporters + `none` (default) / `stdout`; OTLP + dashboards deferred to **Stage 11**. + - Tests/CI: integration tests behind the `integration` build tag in + `backend/internal/inttest` + new `integration.yaml` (testcontainers, Ryuk + off, serial), firing on push and PR. Backend now **hard-depends on Postgres + at boot** (migrations at startup) — a deliberate contract change from + Stage 0, documented in both READMEs. All code stays in the existing + `backend` module under `internal/` (+ `cmd/jetgen`); `go.work` untouched. diff --git a/README.md b/README.md index a52e87b..ce4b719 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,25 @@ supports English Scrabble, Russian Scrabble and Эрудит. go build ./backend/... # per module (the workspace spans several modules) go vet ./backend/... gofmt -l . # must print nothing -go test -count=1 ./backend/... +go test -count=1 ./backend/... # unit tests +go test -tags=integration -count=1 -p=1 ./backend/... # + Postgres (needs Docker) ``` +The `integration`-tagged tests start a throwaway `postgres:17-alpine` container +via testcontainers-go and require a reachable Docker daemon. + ## Run the backend locally +The backend now owns persistence, so it needs Postgres and applies its embedded +migrations at startup: + ```sh -go run ./backend/cmd/backend # serves /healthz and /readyz on :8080 +docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine +BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ + go run ./backend/cmd/backend # serves /healthz and /readyz on :8080 ``` -Configuration is read from the environment: `BACKEND_HTTP_ADDR` (default -`:8080`), `BACKEND_LOG_LEVEL` (`debug|info|warn|error`, default `info`). +Key environment: `BACKEND_HTTP_ADDR` (default `:8080`), `BACKEND_LOG_LEVEL` +(`debug|info|warn|error`, default `info`), `BACKEND_POSTGRES_DSN` (**required**). +The full configuration surface and the go-jet regeneration step live in +[`backend/README.md`](backend/README.md). diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..fcd8053 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,76 @@ +# backend + +Internal-only domain service for the Scrabble platform (module `scrabble/backend`). +It owns identity/sessions, accounts, and — in later stages — the lobby, game +runtime, robot, chat, history and administration. Its only network consumers are +the `gateway` and the platform side-services; it is never exposed publicly. + +As of Stage 1 the backend provides the foundation: configuration, the HTTP +listener with the `/api/v1` route-group skeleton and probes, the Postgres pool +with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache, +and the durable accounts / identities / sessions data model. The session and +account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the +store/service layer they will call. + +## Package layout + +``` +cmd/backend/ # process entrypoint: telemetry -> db+migrate -> cache -> server +cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container +internal/config/ # env configuration (composes postgres + telemetry config) +internal/telemetry/ # OpenTelemetry providers + per-request timing middleware +internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations + migrations/ # embedded *.sql (goose), schema `backend` + jet/ # generated go-jet models + table builders (committed) +internal/account/ # durable accounts + platform/email identities (store) +internal/session/ # opaque tokens, sessions store, write-through cache, service +internal/server/ # gin engine, route groups, X-User-ID middleware, probes +``` + +## Configuration (environment) + +| Variable | Default | Notes | +| --- | --- | --- | +| `BACKEND_HTTP_ADDR` | `:8080` | HTTP listen address. | +| `BACKEND_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. | +| `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. | +| `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. | +| `BACKEND_POSTGRES_MAX_IDLE_CONNS` | `5` | Pool max idle connections. | +| `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. | +| `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. | +| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. | +| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none` or `stdout` (OTLP arrives later). | +| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none` or `stdout`. | + +## Run + +```sh +docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine +BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ + go run ./cmd/backend +``` + +On boot the backend opens the pool, creates the `backend` schema if needed, and +applies the embedded migrations. `GET /healthz` reports liveness; `GET /readyz` +reports 200 only when the database answers and the session cache is warmed. + +## Migrations & generated code + +Migrations are plain goose SQL under `internal/postgres/migrations` (sequential +`NNNNN_name.sql`), embedded and applied at startup. After changing the schema, +regenerate the committed go-jet code (needs Docker): + +```sh +go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp container +``` + +## Tests + +```sh +go test -count=1 ./... # unit tests (no Docker) +go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker) +``` + +Integration tests are guarded by the `integration` build tag and run against a +throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent +rather than skipping. diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 45caee6..e4301af 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -1,20 +1,30 @@ -// Command backend is the Scrabble platform's internal domain service. At this -// stage it boots the HTTP listener with the infrastructure probes only; the -// domain modules described in PLAN.md are added by later stages. +// Command backend is the Scrabble platform's internal domain service. It boots +// the OpenTelemetry runtime, opens the Postgres pool and applies migrations, +// warms the session cache, and serves the HTTP listener with the infrastructure +// probes and the /api/v1 route-group skeleton. Domain endpoints are added by +// later stages described in PLAN.md. package main import ( "context" + "fmt" "log" "os/signal" "syscall" + "time" "go.uber.org/zap" "scrabble/backend/internal/config" + "scrabble/backend/internal/postgres" "scrabble/backend/internal/server" + "scrabble/backend/internal/session" + "scrabble/backend/internal/telemetry" ) +// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. +const telemetryShutdownTimeout = 5 * time.Second + func main() { cfg, err := config.Load() if err != nil { @@ -30,12 +40,55 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - srv := server.New(cfg.HTTPAddr, logger) - if err := srv.Run(ctx); err != nil { - logger.Fatal("backend: server terminated", zap.Error(err)) + if err := run(ctx, cfg, logger); err != nil { + logger.Fatal("backend: terminated", zap.Error(err)) } } +// run wires the process dependencies in order — telemetry, database (with +// migrations), session cache, HTTP server — and blocks until ctx is cancelled. +func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { + tel, err := telemetry.New(ctx, cfg.Telemetry) + if err != nil { + return fmt.Errorf("init telemetry: %w", err) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), telemetryShutdownTimeout) + defer cancel() + if err := tel.Shutdown(shutdownCtx); err != nil { + logger.Warn("telemetry shutdown", zap.Error(err)) + } + }() + + db, err := postgres.Open(ctx, cfg.Postgres, + postgres.WithTracerProvider(tel.TracerProvider()), + postgres.WithMeterProvider(tel.MeterProvider()), + ) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer func() { _ = db.Close() }() + + if err := postgres.ApplyMigrations(ctx, db); err != nil { + return fmt.Errorf("apply migrations: %w", err) + } + logger.Info("database migrations applied") + + sessions := session.NewService(session.NewStore(db), session.NewCache()) + if err := sessions.Warm(ctx); err != nil { + return fmt.Errorf("warm session cache: %w", err) + } + logger.Info("session cache warmed") + + srv := server.New(cfg.HTTPAddr, server.Deps{ + Logger: logger, + DB: db, + PingTimeout: cfg.Postgres.OperationTimeout, + SessionsReady: sessions.Ready, + }) + return srv.Run(ctx) +} + // newLogger builds a production JSON logger at the given level. func newLogger(level string) (*zap.Logger, error) { var lvl zap.AtomicLevel diff --git a/backend/cmd/jetgen/main.go b/backend/cmd/jetgen/main.go new file mode 100644 index 0000000..8103f4c --- /dev/null +++ b/backend/cmd/jetgen/main.go @@ -0,0 +1,142 @@ +// Command jetgen regenerates the go-jet/v2 query-builder code under +// backend/internal/postgres/jet against a transient PostgreSQL instance. +// +// Invoke as `go run ./cmd/jetgen` from inside the backend module. The tool is +// not part of the runtime binary and requires a reachable Docker daemon. +// +// Steps: +// +// 1. start a postgres:17-alpine container via testcontainers-go +// 2. open it with search_path=backend and apply the embedded goose migrations +// 3. drop goose's bookkeeping table so jet does not generate a model for it +// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet +package main + +import ( + "context" + "fmt" + "log" + "net/url" + "os" + "path/filepath" + "runtime" + "time" + + jetpostgres "github.com/go-jet/jet/v2/generator/postgres" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + "scrabble/backend/internal/postgres" +) + +const ( + postgresImage = "postgres:17-alpine" + superuserName = "scrabble" + superuserPassword = "scrabble" + superuserDatabase = "scrabble_backend" + backendSchema = "backend" + containerStartup = 90 * time.Second + jetOutputDirSuffix = "internal/postgres/jet" +) + +func main() { + if err := run(context.Background()); err != nil { + log.Fatalf("jetgen: %v", err) + } +} + +func run(ctx context.Context) error { + outputDir, err := jetOutputDir() + if err != nil { + return err + } + + container, err := tcpostgres.Run(ctx, postgresImage, + tcpostgres.WithDatabase(superuserDatabase), + tcpostgres.WithUsername(superuserName), + tcpostgres.WithPassword(superuserPassword), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(containerStartup), + ), + ) + if err != nil { + return fmt.Errorf("start postgres container: %w", err) + } + defer func() { + if termErr := testcontainers.TerminateContainer(container); termErr != nil { + log.Printf("jetgen: terminate container: %v", termErr) + } + }() + + baseDSN, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return fmt.Errorf("resolve container dsn: %w", err) + } + scopedDSN, err := dsnWithSearchPath(baseDSN, backendSchema) + if err != nil { + return err + } + + cfg := postgres.DefaultConfig() + cfg.DSN = scopedDSN + + db, err := postgres.Open(ctx, cfg) + if err != nil { + return fmt.Errorf("open scoped pool: %w", err) + } + defer func() { _ = db.Close() }() + + if err := postgres.ApplyMigrations(ctx, db); err != nil { + return fmt.Errorf("apply migrations: %w", err) + } + + // jet's generator wipes / on every run; ensure the + // parent exists so the first run on a fresh checkout does not fail. + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("ensure jet output dir: %w", err) + } + + // Drop goose's bookkeeping table so jet does not generate code for it. The + // container is never reused, so this only affects generation. + if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS goose_db_version"); err != nil { + return fmt.Errorf("drop goose_db_version: %w", err) + } + + if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil { + return fmt.Errorf("jet generate: %w", err) + } + + log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema) + return nil +} + +// dsnWithSearchPath rewrites the connection string so every new connection pins +// search_path to the named schema and disables TLS for the local container. +func dsnWithSearchPath(baseDSN, schema string) (string, error) { + parsed, err := url.Parse(baseDSN) + if err != nil { + return "", fmt.Errorf("parse base dsn: %w", err) + } + values := parsed.Query() + values.Set("search_path", schema) + if values.Get("sslmode") == "" { + values.Set("sslmode", "disable") + } + parsed.RawQuery = values.Encode() + return parsed.String(), nil +} + +// jetOutputDir returns the absolute path jet writes into, anchored to the +// backend module via runtime.Caller so the tool runs from any directory. +func jetOutputDir() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("resolve runtime caller for jet output path") + } + // file = .../backend/cmd/jetgen/main.go + moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) + return filepath.Join(moduleRoot, jetOutputDirSuffix), nil +} diff --git a/backend/go.mod b/backend/go.mod index ae35729..143fa55 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,39 +3,109 @@ module scrabble/backend go 1.26.3 require ( + github.com/XSAM/otelsql v0.42.0 github.com/gin-gonic/gin v1.12.0 + github.com/go-jet/jet/v2 v2.14.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/pressly/goose/v3 v3.27.1 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/zap v1.27.1 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgconn v1.14.3 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.3 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgtype v1.14.4 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.2 // indirect + github.com/moby/moby/client v0.4.1 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - go.uber.org/multierr v1.10.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 55d4b9a..cf9ae83 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,20 +1,75 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o= +github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-jet/jet/v2 v2.14.1 h1:wsfD9e7CGP9h46+IFNlftfncBcmVnKddikbTtapQM3M= +github.com/go-jet/jet/v2 v2.14.1/go.mod h1:dqTAECV2Mo3S2NFjbm4vJ1aDruZjhaJ1RAAR8rGUkkc= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -23,73 +78,367 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +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/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgtype v1.14.4 h1:fKuNiCumbKTAIxQwXfB/nsrnkEI6bPJrrSiMKgbJ2j8= +github.com/jackc/pgtype v1.14.4/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= +github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -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/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +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/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go new file mode 100644 index 0000000..26e7444 --- /dev/null +++ b/backend/internal/account/account.go @@ -0,0 +1,203 @@ +// Package account owns durable internal accounts and their platform/email +// identities. First contact from a platform auto-provisions an account bound to +// that identity; guests are session-only and never reach this package. +package account + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Identity kinds recognised by the backend. Email is modelled as an identity +// alongside platform identities; its confirmed flag is driven by the email +// confirm-code flow in a later stage. +const ( + KindTelegram = "telegram" + KindEmail = "email" +) + +// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation. +const uniqueViolation = "23505" + +// ErrNotFound is returned when no account matches the lookup. +var ErrNotFound = errors.New("account: not found") + +// Account is a durable internal account. +type Account struct { + ID uuid.UUID + DisplayName string + PreferredLanguage string + TimeZone string + BlockChat bool + BlockFriendRequests bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// Store is the Postgres-backed query surface for accounts and identities. +type Store struct { + db *sql.DB +} + +// NewStore constructs a Store wrapping db. +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// ProvisionByIdentity returns the account bound to (kind, externalID), creating +// a fresh durable account and identity when none exists yet. It is safe under +// concurrent callers: a losing race on the identity's unique constraint is +// resolved by re-reading the winner's account. A platform identity is recorded +// as confirmed; an email identity starts unconfirmed. +func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) { + acc, err := s.findByIdentity(ctx, kind, externalID) + if err == nil { + return acc, nil + } + if !errors.Is(err, ErrNotFound) { + return Account{}, err + } + + acc, err = s.create(ctx, kind, externalID) + if err != nil { + if isUniqueViolation(err) { + // A concurrent caller created the identity first; return theirs. + return s.findByIdentity(ctx, kind, externalID) + } + return Account{}, err + } + return acc, nil +} + +// GetByID loads the account identified by id, or ErrNotFound when it is absent. +func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) { + stmt := postgres.SELECT(table.Accounts.AllColumns). + FROM(table.Accounts). + WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). + LIMIT(1) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Account{}, ErrNotFound + } + return Account{}, fmt.Errorf("account: get by id %s: %w", id, err) + } + return modelToAccount(row), nil +} + +// findByIdentity joins identities to accounts and returns the matching account, +// or ErrNotFound. +func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) { + stmt := postgres.SELECT(table.Accounts.AllColumns). + FROM(table.Accounts.INNER_JOIN( + table.Identities, + table.Identities.AccountID.EQ(table.Accounts.AccountID), + )). + WHERE( + table.Identities.Kind.EQ(postgres.String(kind)). + AND(table.Identities.ExternalID.EQ(postgres.String(externalID))), + ). + LIMIT(1) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Account{}, ErrNotFound + } + return Account{}, fmt.Errorf("account: find by identity (%s, %s): %w", kind, externalID, err) + } + return modelToAccount(row), nil +} + +// create inserts a new account and its first identity inside one transaction +// and returns the persisted account row. +func (s *Store) create(ctx context.Context, kind, externalID string) (Account, error) { + accountID, err := uuid.NewV7() + if err != nil { + return Account{}, fmt.Errorf("account: new account id: %w", err) + } + identityID, err := uuid.NewV7() + if err != nil { + return Account{}, fmt.Errorf("account: new identity id: %w", err) + } + + var created Account + err = withTx(ctx, s.db, func(tx *sql.Tx) error { + insertAccount := table.Accounts. + INSERT(table.Accounts.AccountID). + VALUES(accountID). + RETURNING(table.Accounts.AllColumns) + + var row model.Accounts + if err := insertAccount.QueryContext(ctx, tx, &row); err != nil { + return err + } + + insertIdentity := table.Identities.INSERT( + table.Identities.IdentityID, + table.Identities.AccountID, + table.Identities.Kind, + table.Identities.ExternalID, + table.Identities.Confirmed, + ).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram) + if _, err := insertIdentity.ExecContext(ctx, tx); err != nil { + return err + } + + created = modelToAccount(row) + return nil + }) + if err != nil { + return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err) + } + return created, nil +} + +// modelToAccount projects a generated model row into the public Account struct. +func modelToAccount(row model.Accounts) Account { + return Account{ + ID: row.AccountID, + DisplayName: row.DisplayName, + PreferredLanguage: row.PreferredLanguage, + TimeZone: row.TimeZone, + BlockChat: row.BlockChat, + BlockFriendRequests: row.BlockFriendRequests, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +// isUniqueViolation reports whether err is a PostgreSQL unique-constraint +// violation, used to collapse a concurrent-provision race into a re-read. +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation +} + +// withTx wraps fn in a transaction, committing on nil and rolling back on error. +func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit tx: %w", err) + } + return nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1c3cb1e..50eb331 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,6 +5,11 @@ package config import ( "fmt" "os" + "strconv" + "time" + + "scrabble/backend/internal/postgres" + "scrabble/backend/internal/telemetry" ) // Config holds the backend's runtime configuration. @@ -13,6 +18,10 @@ type Config struct { HTTPAddr string // LogLevel is the zap log level: "debug", "info", "warn" or "error". LogLevel string + // Postgres configures the primary database pool. + Postgres postgres.Config + // Telemetry configures the OpenTelemetry providers. + Telemetry telemetry.Config } // Defaults applied when the corresponding environment variable is unset. @@ -21,12 +30,35 @@ const ( defaultLogLevel = "info" ) -// Load reads the configuration from the environment, applies defaults for -// unset variables, and validates the result. +// Load reads the configuration from the environment, applies defaults for unset +// variables, and validates the result. func Load() (Config, error) { + pg := postgres.DefaultConfig() + pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN") + var err error + if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil { + return Config{}, err + } + if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil { + return Config{}, err + } + if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil { + return Config{}, err + } + if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil { + return Config{}, err + } + + tel := telemetry.DefaultConfig() + tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName) + tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter) + tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter) + c := Config{ - HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), - LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), + HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), + LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), + Postgres: pg, + Telemetry: tel, } if err := c.validate(); err != nil { return Config{}, err @@ -44,6 +76,12 @@ func (c Config) validate() error { if c.HTTPAddr == "" { return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty") } + if err := c.Postgres.Validate(); err != nil { + return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err) + } + if err := c.Telemetry.Validate(); err != nil { + return fmt.Errorf("config: %w", err) + } return nil } @@ -55,3 +93,31 @@ func envOr(key, fallback string) string { } return fallback } + +// envInt parses the environment variable named key as an int, returning +// fallback when it is unset and an error when it is set but malformed. +func envInt(key string, fallback int) (int, error) { + v := os.Getenv(key) + if v == "" { + return fallback, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("config: %s: %w", key, err) + } + return n, nil +} + +// envDuration parses the environment variable named key as a Go duration, +// returning fallback when it is unset and an error when it is set but malformed. +func envDuration(key string, fallback time.Duration) (time.Duration, error) { + v := os.Getenv(key) + if v == "" { + return fallback, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("config: %s: %w", key, err) + } + return d, nil +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index efca0f8..4caa6ff 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,12 +1,22 @@ package config -import "testing" +import ( + "testing" + "time" -// TestLoadDefaults verifies that Load applies defaults when the environment is -// empty. + "scrabble/backend/internal/postgres" + "scrabble/backend/internal/telemetry" +) + +// testDSN is a syntactically valid DSN used to satisfy the required-DSN check. +const testDSN = "postgres://u:p@localhost:5432/db?search_path=backend&sslmode=disable" + +// TestLoadDefaults verifies that Load applies defaults when only the required +// DSN is set. func TestLoadDefaults(t *testing.T) { t.Setenv("BACKEND_HTTP_ADDR", "") t.Setenv("BACKEND_LOG_LEVEL", "") + t.Setenv("BACKEND_POSTGRES_DSN", testDSN) c, err := Load() if err != nil { @@ -18,12 +28,29 @@ func TestLoadDefaults(t *testing.T) { if c.LogLevel != defaultLogLevel { t.Errorf("LogLevel = %q, want %q", c.LogLevel, defaultLogLevel) } + if c.Postgres.DSN != testDSN { + t.Errorf("Postgres.DSN = %q, want %q", c.Postgres.DSN, testDSN) + } + if c.Postgres.MaxOpenConns != postgres.DefaultMaxOpenConns { + t.Errorf("Postgres.MaxOpenConns = %d, want %d", c.Postgres.MaxOpenConns, postgres.DefaultMaxOpenConns) + } + if c.Telemetry.ServiceName != telemetry.DefaultServiceName { + t.Errorf("Telemetry.ServiceName = %q, want %q", c.Telemetry.ServiceName, telemetry.DefaultServiceName) + } + if c.Telemetry.TracesExporter != telemetry.ExporterNone { + t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterNone) + } } // TestLoadOverrides verifies that environment variables override the defaults. func TestLoadOverrides(t *testing.T) { + t.Setenv("BACKEND_POSTGRES_DSN", testDSN) t.Setenv("BACKEND_HTTP_ADDR", "127.0.0.1:9090") t.Setenv("BACKEND_LOG_LEVEL", "debug") + t.Setenv("BACKEND_POSTGRES_MAX_OPEN_CONNS", "7") + t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "3s") + t.Setenv("BACKEND_SERVICE_NAME", "scrabble-test") + t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "stdout") c, err := Load() if err != nil { @@ -33,14 +60,63 @@ func TestLoadOverrides(t *testing.T) { t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, "127.0.0.1:9090") } if c.LogLevel != "debug" { - t.Errorf("LogLevel = %q, want %q", c.LogLevel, "debug") + t.Errorf("LogLevel = %q", c.LogLevel) + } + if c.Postgres.MaxOpenConns != 7 { + t.Errorf("Postgres.MaxOpenConns = %d, want 7", c.Postgres.MaxOpenConns) + } + if c.Postgres.OperationTimeout != 3*time.Second { + t.Errorf("Postgres.OperationTimeout = %s, want 3s", c.Postgres.OperationTimeout) + } + if c.Telemetry.ServiceName != "scrabble-test" { + t.Errorf("Telemetry.ServiceName = %q", c.Telemetry.ServiceName) + } + if c.Telemetry.TracesExporter != telemetry.ExporterStdout { + t.Errorf("Telemetry.TracesExporter = %q, want %q", c.Telemetry.TracesExporter, telemetry.ExporterStdout) + } +} + +// TestLoadRejectsMissingDSN verifies that an empty DSN fails validation. +func TestLoadRejectsMissingDSN(t *testing.T) { + t.Setenv("BACKEND_POSTGRES_DSN", "") + if _, err := Load(); err == nil { + t.Fatal("Load: expected an error for a missing DSN, got nil") } } // TestLoadRejectsInvalidLevel verifies that an unknown log level is rejected. func TestLoadRejectsInvalidLevel(t *testing.T) { + t.Setenv("BACKEND_POSTGRES_DSN", testDSN) t.Setenv("BACKEND_LOG_LEVEL", "verbose") if _, err := Load(); err == nil { t.Fatal("Load: expected an error for an invalid log level, got nil") } } + +// TestLoadRejectsMalformedInt verifies that a non-numeric pool size is rejected. +func TestLoadRejectsMalformedInt(t *testing.T) { + t.Setenv("BACKEND_POSTGRES_DSN", testDSN) + t.Setenv("BACKEND_POSTGRES_MAX_OPEN_CONNS", "lots") + if _, err := Load(); err == nil { + t.Fatal("Load: expected an error for a malformed int, got nil") + } +} + +// TestLoadRejectsMalformedDuration verifies that a malformed duration is rejected. +func TestLoadRejectsMalformedDuration(t *testing.T) { + t.Setenv("BACKEND_POSTGRES_DSN", testDSN) + t.Setenv("BACKEND_POSTGRES_OPERATION_TIMEOUT", "soon") + if _, err := Load(); err == nil { + t.Fatal("Load: expected an error for a malformed duration, got nil") + } +} + +// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the MVP +// set is rejected. +func TestLoadRejectsUnsupportedExporter(t *testing.T) { + t.Setenv("BACKEND_POSTGRES_DSN", testDSN) + t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp") + if _, err := Load(); err == nil { + t.Fatal("Load: expected an error for an unsupported exporter, got nil") + } +} diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go new file mode 100644 index 0000000..695e423 --- /dev/null +++ b/backend/internal/inttest/account_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// TestAccountProvisionByIdentity covers find-or-create semantics, distinct +// accounts per identity, GetByID, and the identity confirmed flag per kind. +func TestAccountProvisionByIdentity(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + tgExternal := "tg-" + uuid.NewString() + first, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExternal) + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + if first.ID == uuid.Nil { + t.Fatal("expected a non-nil account id") + } + if first.PreferredLanguage != "en" { + t.Errorf("PreferredLanguage = %q, want default en", first.PreferredLanguage) + } + if first.TimeZone != "UTC" { + t.Errorf("TimeZone = %q, want default UTC", first.TimeZone) + } + + // Re-provisioning the same identity returns the same account. + again, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExternal) + if err != nil { + t.Fatalf("re-provision telegram: %v", err) + } + if again.ID != first.ID { + t.Errorf("re-provision id = %s, want %s", again.ID, first.ID) + } + + // A different identity yields a different account. + other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision other telegram: %v", err) + } + if other.ID == first.ID { + t.Error("distinct identity must map to a distinct account") + } + + // GetByID round-trips, and a random id reports ErrNotFound. + got, err := store.GetByID(ctx, first.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if got.ID != first.ID { + t.Errorf("get id = %s, want %s", got.ID, first.ID) + } + if _, err := store.GetByID(ctx, uuid.New()); !errors.Is(err, account.ErrNotFound) { + t.Errorf("get missing = %v, want ErrNotFound", err) + } + + // A platform identity is confirmed; an email identity starts unconfirmed. + if c := identityConfirmed(t, account.KindTelegram, tgExternal); !c { + t.Error("telegram identity must be confirmed") + } + emailExternal := "e-" + uuid.NewString() + "@example.com" + if _, err := store.ProvisionByIdentity(ctx, account.KindEmail, emailExternal); err != nil { + t.Fatalf("provision email: %v", err) + } + if c := identityConfirmed(t, account.KindEmail, emailExternal); c { + t.Error("email identity must start unconfirmed") + } +} + +// identityConfirmed reads the confirmed flag for one identity directly. +func identityConfirmed(t *testing.T, kind, externalID string) bool { + t.Helper() + var confirmed bool + err := testDB.QueryRowContext(context.Background(), + "SELECT confirmed FROM identities WHERE kind = $1 AND external_id = $2", + kind, externalID, + ).Scan(&confirmed) + if err != nil { + t.Fatalf("read confirmed for (%s, %s): %v", kind, externalID, err) + } + return confirmed +} diff --git a/backend/internal/inttest/doc.go b/backend/internal/inttest/doc.go new file mode 100644 index 0000000..49504ca --- /dev/null +++ b/backend/internal/inttest/doc.go @@ -0,0 +1,12 @@ +// Package inttest holds the Postgres-backed integration tests for the backend. +// +// The tests are guarded by the `integration` build tag and run against a +// throwaway postgres:17-alpine container started with testcontainers-go, so a +// reachable Docker daemon is required. Run them with: +// +// go test -tags=integration ./backend/... +// +// They fail loudly when Docker is unavailable rather than skipping, per +// docs/TESTING.md. This file carries no build tag so the package is non-empty +// in the default (no-tag) build. +package inttest diff --git a/backend/internal/inttest/main_test.go b/backend/internal/inttest/main_test.go new file mode 100644 index 0000000..b4b1da2 --- /dev/null +++ b/backend/internal/inttest/main_test.go @@ -0,0 +1,109 @@ +//go:build integration + +package inttest + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "os" + "testing" + "time" + + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + "scrabble/backend/internal/postgres" +) + +// testDB is the shared, migrated pool every integration test runs against. It is +// hydrated once by TestMain. +var testDB *sql.DB + +const ( + pgImage = "postgres:17-alpine" + pgDatabase = "scrabble_backend" + pgUser = "scrabble" + pgPassword = "scrabble" + pgSchema = "backend" + containerStartup = 90 * time.Second + containerShutdown = 30 * time.Second +) + +// TestMain starts one Postgres container, applies the migrations, and shares the +// resulting pool with every test. Any setup failure aborts the suite loudly +// (exit 1) rather than skipping coverage. +func TestMain(m *testing.M) { + code, err := runSuite(m) + if err != nil { + fmt.Fprintln(os.Stderr, "inttest:", err) + os.Exit(1) + } + os.Exit(code) +} + +func runSuite(m *testing.M) (int, error) { + ctx := context.Background() + + container, err := tcpostgres.Run(ctx, pgImage, + tcpostgres.WithDatabase(pgDatabase), + tcpostgres.WithUsername(pgUser), + tcpostgres.WithPassword(pgPassword), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(containerStartup), + ), + ) + if err != nil { + return 0, fmt.Errorf("start postgres container: %w", err) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), containerShutdown) + defer cancel() + if termErr := container.Terminate(shutdownCtx); termErr != nil { + fmt.Fprintln(os.Stderr, "inttest: terminate container:", termErr) + } + }() + + baseDSN, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return 0, fmt.Errorf("resolve container dsn: %w", err) + } + dsn, err := withSearchPath(baseDSN, pgSchema) + if err != nil { + return 0, err + } + + cfg := postgres.DefaultConfig() + cfg.DSN = dsn + db, err := postgres.Open(ctx, cfg) + if err != nil { + return 0, fmt.Errorf("open pool: %w", err) + } + defer func() { _ = db.Close() }() + + if err := postgres.ApplyMigrations(ctx, db); err != nil { + return 0, fmt.Errorf("apply migrations: %w", err) + } + + testDB = db + return m.Run(), nil +} + +// withSearchPath rewrites dsn so every connection pins search_path to schema. +func withSearchPath(dsn, schema string) (string, error) { + u, err := url.Parse(dsn) + if err != nil { + return "", fmt.Errorf("parse dsn: %w", err) + } + q := u.Query() + q.Set("search_path", schema) + if q.Get("sslmode") == "" { + q.Set("sslmode", "disable") + } + u.RawQuery = q.Encode() + return u.String(), nil +} diff --git a/backend/internal/inttest/migrate_test.go b/backend/internal/inttest/migrate_test.go new file mode 100644 index 0000000..a4b02a6 --- /dev/null +++ b/backend/internal/inttest/migrate_test.go @@ -0,0 +1,25 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + + "scrabble/backend/internal/postgres" +) + +// TestApplyMigrationsIdempotent re-applies the migrations against the already +// migrated database and confirms the expected tables are queryable. +func TestApplyMigrationsIdempotent(t *testing.T) { + ctx := context.Background() + if err := postgres.ApplyMigrations(ctx, testDB); err != nil { + t.Fatalf("re-apply migrations: %v", err) + } + for _, table := range []string{"accounts", "identities", "sessions"} { + var n int + if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM "+table).Scan(&n); err != nil { + t.Errorf("count %s: %v", table, err) + } + } +} diff --git a/backend/internal/inttest/server_test.go b/backend/internal/inttest/server_test.go new file mode 100644 index 0000000..2627ae0 --- /dev/null +++ b/backend/internal/inttest/server_test.go @@ -0,0 +1,41 @@ +//go:build integration + +package inttest + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "scrabble/backend/internal/server" + "scrabble/backend/internal/session" +) + +// TestReadyzWithRealDatabase exercises the assembled server against the live +// container: /readyz answers 200 once the database pings and the session cache +// is warmed, closing the gap the unit test (nil database) cannot cover. +func TestReadyzWithRealDatabase(t *testing.T) { + svc := session.NewService(session.NewStore(testDB), session.NewCache()) + if err := svc.Warm(context.Background()); err != nil { + t.Fatalf("warm session cache: %v", err) + } + + srv := server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + PingTimeout: 5 * time.Second, + SessionsReady: svc.Ready, + }) + + for _, path := range []string{"/healthz", "/readyz"} { + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Errorf("%s status = %d, want 200", path, rec.Code) + } + } +} diff --git a/backend/internal/inttest/session_test.go b/backend/internal/inttest/session_test.go new file mode 100644 index 0000000..95f4875 --- /dev/null +++ b/backend/internal/inttest/session_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/session" +) + +// TestSessionLifecycle covers create, cache-hit resolve, DB-fallback resolve +// after a cold cache warm, idempotent revoke, and post-revoke resolution. +func TestSessionLifecycle(t *testing.T) { + ctx := context.Background() + + acc, err := account.NewStore(testDB).ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision account: %v", err) + } + + store := session.NewStore(testDB) + svc := session.NewService(store, session.NewCache()) + + token, sess, err := svc.Create(ctx, acc.ID) + if err != nil { + t.Fatalf("create session: %v", err) + } + if sess.AccountID != acc.ID { + t.Errorf("session account = %s, want %s", sess.AccountID, acc.ID) + } + if token == sess.TokenHash { + t.Error("plaintext token must not equal the stored hash") + } + + // Resolve via the warm write-through cache. + got, err := svc.Resolve(ctx, token) + if err != nil { + t.Fatalf("resolve (cache): %v", err) + } + if got.ID != sess.ID { + t.Errorf("resolve id = %s, want %s", got.ID, sess.ID) + } + + // An unknown token is not found. + if _, err := svc.Resolve(ctx, "not-a-real-token"); !errors.Is(err, session.ErrNotFound) { + t.Errorf("resolve unknown = %v, want ErrNotFound", err) + } + + // A fresh service with a cold cache resolves through the DB after Warm. + cold := session.NewCache() + svc2 := session.NewService(store, cold) + if err := svc2.Warm(ctx); err != nil { + t.Fatalf("warm: %v", err) + } + if !cold.Ready() { + t.Error("cache must be ready after Warm") + } + if _, ok := cold.Get(session.HashToken(token)); !ok { + t.Error("Warm must load the active session into the cache") + } + if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID { + t.Errorf("resolve after warm = (%s, %v), want %s", got2.ID, err, sess.ID) + } + + // Revoke, then the token no longer resolves; revoke again is a no-op. + if err := svc.Revoke(ctx, token); err != nil { + t.Fatalf("revoke: %v", err) + } + if _, err := svc.Resolve(ctx, token); !errors.Is(err, session.ErrNotFound) { + t.Errorf("resolve after revoke = %v, want ErrNotFound", err) + } + if err := svc.Revoke(ctx, token); err != nil { + t.Errorf("idempotent revoke: %v", err) + } +} diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go new file mode 100644 index 0000000..ba897b7 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -0,0 +1,24 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type Accounts struct { + AccountID uuid.UUID `sql:"primary_key"` + DisplayName string + PreferredLanguage string + TimeZone string + BlockChat bool + BlockFriendRequests bool + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/identities.go b/backend/internal/postgres/jet/backend/model/identities.go new file mode 100644 index 0000000..74963cd --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/identities.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type Identities struct { + IdentityID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + Kind string + ExternalID string + Confirmed bool + CreatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/sessions.go b/backend/internal/postgres/jet/backend/model/sessions.go new file mode 100644 index 0000000..4694d73 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/sessions.go @@ -0,0 +1,23 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type Sessions struct { + SessionID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + TokenHash string + Status string + CreatedAt time.Time + LastSeenAt *time.Time + RevokedAt *time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go new file mode 100644 index 0000000..3c1a1a2 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Accounts = newAccountsTable("backend", "accounts", "") + +type accountsTable struct { + postgres.Table + + // Columns + AccountID postgres.ColumnString + DisplayName postgres.ColumnString + PreferredLanguage postgres.ColumnString + TimeZone postgres.ColumnString + BlockChat postgres.ColumnBool + BlockFriendRequests postgres.ColumnBool + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AccountsTable struct { + accountsTable + + EXCLUDED accountsTable +} + +// AS creates new AccountsTable with assigned alias +func (a AccountsTable) AS(alias string) *AccountsTable { + return newAccountsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AccountsTable with assigned schema name +func (a AccountsTable) FromSchema(schemaName string) *AccountsTable { + return newAccountsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AccountsTable with assigned table prefix +func (a AccountsTable) WithPrefix(prefix string) *AccountsTable { + return newAccountsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AccountsTable with assigned table suffix +func (a AccountsTable) WithSuffix(suffix string) *AccountsTable { + return newAccountsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAccountsTable(schemaName, tableName, alias string) *AccountsTable { + return &AccountsTable{ + accountsTable: newAccountsTableImpl(schemaName, tableName, alias), + EXCLUDED: newAccountsTableImpl("", "excluded", ""), + } +} + +func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { + var ( + AccountIDColumn = postgres.StringColumn("account_id") + DisplayNameColumn = postgres.StringColumn("display_name") + PreferredLanguageColumn = postgres.StringColumn("preferred_language") + TimeZoneColumn = postgres.StringColumn("time_zone") + BlockChatColumn = postgres.BoolColumn("block_chat") + BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return accountsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + AccountID: AccountIDColumn, + DisplayName: DisplayNameColumn, + PreferredLanguage: PreferredLanguageColumn, + TimeZone: TimeZoneColumn, + BlockChat: BlockChatColumn, + BlockFriendRequests: BlockFriendRequestsColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/identities.go b/backend/internal/postgres/jet/backend/table/identities.go new file mode 100644 index 0000000..010f83a --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/identities.go @@ -0,0 +1,93 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Identities = newIdentitiesTable("backend", "identities", "") + +type identitiesTable struct { + postgres.Table + + // Columns + IdentityID postgres.ColumnString + AccountID postgres.ColumnString + Kind postgres.ColumnString + ExternalID postgres.ColumnString + Confirmed postgres.ColumnBool + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type IdentitiesTable struct { + identitiesTable + + EXCLUDED identitiesTable +} + +// AS creates new IdentitiesTable with assigned alias +func (a IdentitiesTable) AS(alias string) *IdentitiesTable { + return newIdentitiesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new IdentitiesTable with assigned schema name +func (a IdentitiesTable) FromSchema(schemaName string) *IdentitiesTable { + return newIdentitiesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new IdentitiesTable with assigned table prefix +func (a IdentitiesTable) WithPrefix(prefix string) *IdentitiesTable { + return newIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new IdentitiesTable with assigned table suffix +func (a IdentitiesTable) WithSuffix(suffix string) *IdentitiesTable { + return newIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newIdentitiesTable(schemaName, tableName, alias string) *IdentitiesTable { + return &IdentitiesTable{ + identitiesTable: newIdentitiesTableImpl(schemaName, tableName, alias), + EXCLUDED: newIdentitiesTableImpl("", "excluded", ""), + } +} + +func newIdentitiesTableImpl(schemaName, tableName, alias string) identitiesTable { + var ( + IdentityIDColumn = postgres.StringColumn("identity_id") + AccountIDColumn = postgres.StringColumn("account_id") + KindColumn = postgres.StringColumn("kind") + ExternalIDColumn = postgres.StringColumn("external_id") + ConfirmedColumn = postgres.BoolColumn("confirmed") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{IdentityIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{ConfirmedColumn, CreatedAtColumn} + ) + + return identitiesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + IdentityID: IdentityIDColumn, + AccountID: AccountIDColumn, + Kind: KindColumn, + ExternalID: ExternalIDColumn, + Confirmed: ConfirmedColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/sessions.go b/backend/internal/postgres/jet/backend/table/sessions.go new file mode 100644 index 0000000..5a08254 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/sessions.go @@ -0,0 +1,96 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Sessions = newSessionsTable("backend", "sessions", "") + +type sessionsTable struct { + postgres.Table + + // Columns + SessionID postgres.ColumnString + AccountID postgres.ColumnString + TokenHash postgres.ColumnString + Status postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + LastSeenAt postgres.ColumnTimestampz + RevokedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type SessionsTable struct { + sessionsTable + + EXCLUDED sessionsTable +} + +// AS creates new SessionsTable with assigned alias +func (a SessionsTable) AS(alias string) *SessionsTable { + return newSessionsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new SessionsTable with assigned schema name +func (a SessionsTable) FromSchema(schemaName string) *SessionsTable { + return newSessionsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new SessionsTable with assigned table prefix +func (a SessionsTable) WithPrefix(prefix string) *SessionsTable { + return newSessionsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new SessionsTable with assigned table suffix +func (a SessionsTable) WithSuffix(suffix string) *SessionsTable { + return newSessionsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newSessionsTable(schemaName, tableName, alias string) *SessionsTable { + return &SessionsTable{ + sessionsTable: newSessionsTableImpl(schemaName, tableName, alias), + EXCLUDED: newSessionsTableImpl("", "excluded", ""), + } +} + +func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable { + var ( + SessionIDColumn = postgres.StringColumn("session_id") + AccountIDColumn = postgres.StringColumn("account_id") + TokenHashColumn = postgres.StringColumn("token_hash") + StatusColumn = postgres.StringColumn("status") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at") + RevokedAtColumn = postgres.TimestampzColumn("revoked_at") + allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn} + defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn} + ) + + return sessionsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + SessionID: SessionIDColumn, + AccountID: AccountIDColumn, + TokenHash: TokenHashColumn, + Status: StatusColumn, + CreatedAt: CreatedAtColumn, + LastSeenAt: LastSeenAtColumn, + RevokedAt: RevokedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go new file mode 100644 index 0000000..e6aee6d --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -0,0 +1,16 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke +// this method only once at the beginning of the program. +func UseSchema(schema string) { + Accounts = Accounts.FromSchema(schema) + Identities = Identities.FromSchema(schema) + Sessions = Sessions.FromSchema(schema) +} diff --git a/backend/internal/postgres/migrate.go b/backend/internal/postgres/migrate.go new file mode 100644 index 0000000..5bb640a --- /dev/null +++ b/backend/internal/postgres/migrate.go @@ -0,0 +1,130 @@ +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io/fs" + "strings" + "sync" + "time" + + "github.com/pressly/goose/v3" + + "scrabble/backend/internal/postgres/migrations" +) + +// schemaName is the Postgres schema owned by the backend service. Every backend +// table lives here, and the DSN pins search_path to it. +const schemaName = "backend" + +// migrationRetryAttempts and migrationRetryBackoff bound the transient-error +// retry around ApplyMigrations. A freshly started Postgres — notably a test +// container — can reset a pooled connection moments after it reports ready, +// which surfaces as "bad connection" mid-migration; a handful of quick retries +// ride over that without masking real failures. +const ( + migrationRetryAttempts = 5 + migrationRetryBackoff = 250 * time.Millisecond +) + +// gooseMu serialises access to goose's package-level filesystem state so a +// second caller in the same process cannot race on goose.SetBaseFS. +var gooseMu sync.Mutex + +// ApplyMigrations runs every pending Up migration embedded in the backend +// binary against db. The schema is created upfront so goose's bookkeeping table +// (`goose_db_version`, scoped to the DSN search_path) has somewhere to land +// before the first migration runs; migration 00001_init.sql re-asserts the +// schema with IF NOT EXISTS, so the double-create is idempotent. +// +// The apply is retried on transient connection errors. Both steps are +// idempotent, so a retry after a dropped connection resumes from the last +// committed migration. +func ApplyMigrations(ctx context.Context, db *sql.DB) error { + return retryOnTransient(ctx, migrationRetryAttempts, migrationRetryBackoff, func() error { + if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS "+schemaName); err != nil { + return fmt.Errorf("ensure backend schema: %w", err) + } + if err := runMigrations(ctx, db, migrations.Migrations(), "."); err != nil { + return fmt.Errorf("apply backend migrations: %w", err) + } + return nil + }) +} + +// runMigrations applies every pending Up migration found under dir inside fsys +// against db. The PostgreSQL dialect is forced; goose's package-level base FS is +// restored on the way out so a second caller in the same process is safe. dir +// is "." when the migration files sit at the embed root. +func runMigrations(ctx context.Context, db *sql.DB, fsys fs.FS, dir string) error { + if db == nil { + return errors.New("run migrations: nil db") + } + if fsys == nil { + return errors.New("run migrations: nil fs") + } + + gooseMu.Lock() + defer gooseMu.Unlock() + + goose.SetBaseFS(fsys) + defer goose.SetBaseFS(nil) + + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("run migrations: set dialect: %w", err) + } + if err := goose.UpContext(ctx, db, dir); err != nil { + return fmt.Errorf("run migrations: %w", err) + } + return nil +} + +// retryOnTransient runs op up to attempts times, retrying only when op fails +// with a transient connection error — a dropped, reset, or refused connection, +// as opposed to a deterministic SQL error. It waits backoff between attempts and +// stops early if ctx is cancelled. +func retryOnTransient(ctx context.Context, attempts int, backoff time.Duration, op func() error) error { + var err error + for attempt := 1; attempt <= attempts; attempt++ { + if err = op(); err == nil { + return nil + } + if attempt == attempts || !isTransientConnError(err) { + return err + } + select { + case <-ctx.Done(): + return errors.Join(err, ctx.Err()) + case <-time.After(backoff): + } + } + return err +} + +// isTransientConnError reports whether err is a transient connection-level +// failure worth retrying, leaving deterministic SQL errors (syntax, constraint +// violations) to fail fast. +func isTransientConnError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, driver.ErrBadConn) { + return true + } + msg := strings.ToLower(err.Error()) + for _, s := range []string{ + "bad connection", + "connection refused", + "connection reset", + "broken pipe", + "server closed the connection", + } { + if strings.Contains(msg, s) { + return true + } + } + return false +} diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql new file mode 100644 index 0000000..85c5f16 --- /dev/null +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -0,0 +1,60 @@ +-- +goose Up +-- Initial schema for the Scrabble backend service: durable accounts, their +-- platform/email identities, and opaque server sessions. +-- +-- Every backend table lives in the `backend` schema. The schema is created here +-- so a fresh database can apply this migration, and search_path is pinned for +-- the rest of the migration so the CREATE statements land in `backend` without +-- qualifying every object. Production also pins search_path via +-- BACKEND_POSTGRES_DSN. +CREATE SCHEMA IF NOT EXISTS backend; +SET search_path = backend, pg_catalog; + +-- Durable internal accounts. Guests are session-only and never reach this table. +CREATE TABLE accounts ( + account_id uuid PRIMARY KEY, + display_name text NOT NULL DEFAULT '', + preferred_language text NOT NULL DEFAULT 'en', + time_zone text NOT NULL DEFAULT 'UTC', + block_chat boolean NOT NULL DEFAULT false, + block_friend_requests boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru')) +); + +-- Platform and email identities attached to an account. external_id is the +-- platform user id (kind='telegram') or the email address (kind='email'); +-- confirmed flips true once an email confirm-code is verified (later stages). +CREATE TABLE identities ( + identity_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + kind text NOT NULL, + external_id text NOT NULL, + confirmed boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email')), + CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id) +); +CREATE INDEX identities_account_idx ON identities (account_id); + +-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer +-- token; the plaintext token is never stored. Sessions are revoke-only (no +-- TTL): status moves active -> revoked and revoked_at is stamped. +CREATE TABLE sessions ( + session_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + token_hash text NOT NULL, + status text NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz, + revoked_at timestamptz, + CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')), + CONSTRAINT sessions_token_hash_key UNIQUE (token_hash) +); +CREATE INDEX sessions_account_idx ON sessions (account_id); + +-- +goose Down +DROP TABLE sessions; +DROP TABLE identities; +DROP TABLE accounts; diff --git a/backend/internal/postgres/migrations/embed.go b/backend/internal/postgres/migrations/embed.go new file mode 100644 index 0000000..f67714e --- /dev/null +++ b/backend/internal/postgres/migrations/embed.go @@ -0,0 +1,16 @@ +// Package migrations exposes the goose migrations applied at backend startup. +package migrations + +import ( + "embed" + "io/fs" +) + +//go:embed *.sql +var migrationFiles embed.FS + +// Migrations returns the embedded goose migration filesystem. The migration +// files sit at the FS root, so callers pass "." as the directory argument. +func Migrations() fs.FS { + return migrationFiles +} diff --git a/backend/internal/postgres/postgres.go b/backend/internal/postgres/postgres.go new file mode 100644 index 0000000..b8eb7a4 --- /dev/null +++ b/backend/internal/postgres/postgres.go @@ -0,0 +1,177 @@ +// Package postgres opens the backend's Postgres pool and applies the embedded +// goose migrations into the `backend` schema at startup. +// +// The pool is a standard library *sql.DB backed by the pgx driver (registered +// through pgx/stdlib) and instrumented with otelsql, so go-jet queries run over +// database/sql while statement spans and connection-pool metrics flow into +// OpenTelemetry. The DSN must pin search_path to the backend schema. +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/XSAM/otelsql" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" +) + +// Default pool tuning applied by DefaultConfig. +const ( + DefaultMaxOpenConns = 25 + DefaultMaxIdleConns = 5 + DefaultConnMaxLifetime = 30 * time.Minute + DefaultOperationTimeout = 5 * time.Second +) + +// dbSystemAttribute identifies the wrapped backend in OpenTelemetry spans +// without pinning the package to a specific semconv release. +var dbSystemAttribute = attribute.String("db.system", "postgresql") + +// Config describes how to open the backend Postgres pool. +type Config struct { + // DSN is the pgx/libpq connection string. It must pin search_path to the + // backend schema, e.g. "postgres://…/db?search_path=backend&sslmode=disable". + DSN string + // MaxOpenConns bounds the pool's open connections (database/sql). + MaxOpenConns int + // MaxIdleConns bounds the pool's idle connections (database/sql). + MaxIdleConns int + // ConnMaxLifetime caps how long a pooled connection may be reused. + ConnMaxLifetime time.Duration + // OperationTimeout bounds a single connect attempt and the startup Ping. + OperationTimeout time.Duration +} + +// DefaultConfig returns a Config carrying the default pool tuning and an empty +// DSN. Callers fill DSN from the environment before opening. +func DefaultConfig() Config { + return Config{ + MaxOpenConns: DefaultMaxOpenConns, + MaxIdleConns: DefaultMaxIdleConns, + ConnMaxLifetime: DefaultConnMaxLifetime, + OperationTimeout: DefaultOperationTimeout, + } +} + +// Validate reports whether the configuration is usable. +func (c Config) Validate() error { + if strings.TrimSpace(c.DSN) == "" { + return errors.New("postgres: DSN must not be empty") + } + if c.MaxOpenConns <= 0 { + return fmt.Errorf("postgres: MaxOpenConns must be positive, got %d", c.MaxOpenConns) + } + if c.MaxIdleConns < 0 { + return fmt.Errorf("postgres: MaxIdleConns must not be negative, got %d", c.MaxIdleConns) + } + if c.OperationTimeout <= 0 { + return fmt.Errorf("postgres: OperationTimeout must be positive, got %s", c.OperationTimeout) + } + return nil +} + +// Option configures the OpenTelemetry providers attached to a pool by Open. +// Unset providers fall back to the OpenTelemetry global providers. +type Option func(*options) + +type options struct { + tracerProvider trace.TracerProvider + meterProvider metric.MeterProvider +} + +// WithTracerProvider sets the tracer provider used for SQL statement spans. +func WithTracerProvider(tp trace.TracerProvider) Option { + return func(o *options) { o.tracerProvider = tp } +} + +// WithMeterProvider sets the meter provider used for connection-pool metrics. +func WithMeterProvider(mp metric.MeterProvider) Option { + return func(o *options) { o.meterProvider = mp } +} + +func evalOptions(opts []Option) options { + var resolved options + for _, opt := range opts { + if opt != nil { + opt(&resolved) + } + } + return resolved +} + +func (o options) otelsqlOptions() []otelsql.Option { + out := []otelsql.Option{otelsql.WithAttributes(dbSystemAttribute)} + if o.tracerProvider != nil { + out = append(out, otelsql.WithTracerProvider(o.tracerProvider)) + } + if o.meterProvider != nil { + out = append(out, otelsql.WithMeterProvider(o.meterProvider)) + } + return out +} + +// Open opens the instrumented pool described by cfg, registers connection-pool +// metrics, and verifies connectivity with a bounded Ping. Closing the returned +// *sql.DB is the caller's responsibility. +func Open(ctx context.Context, cfg Config, opts ...Option) (*sql.DB, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("open postgres: %w", err) + } + resolved := evalOptions(opts) + + pgxCfg, err := pgx.ParseConfig(cfg.DSN) + if err != nil { + return nil, fmt.Errorf("open postgres: parse dsn: %w", err) + } + pgxCfg.ConnectTimeout = cfg.OperationTimeout + registeredName := stdlib.RegisterConnConfig(pgxCfg) + + db, err := otelsql.Open("pgx", registeredName, resolved.otelsqlOptions()...) + if err != nil { + stdlib.UnregisterConnConfig(registeredName) + return nil, fmt.Errorf("open postgres: otelsql open: %w", err) + } + + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(cfg.ConnMaxLifetime) + + if _, err := otelsql.RegisterDBStatsMetrics(db, resolved.otelsqlOptions()...); err != nil { + _ = db.Close() + stdlib.UnregisterConnConfig(registeredName) + return nil, fmt.Errorf("open postgres: register db stats: %w", err) + } + + if err := Ping(ctx, db, cfg.OperationTimeout); err != nil { + _ = db.Close() + stdlib.UnregisterConnConfig(registeredName) + return nil, err + } + return db, nil +} + +// Ping bounds db.PingContext under timeout and wraps the error so startup +// failures are easy to spot in service logs. The same call backs the /readyz +// probe. timeout is typically Config.OperationTimeout. +func Ping(ctx context.Context, db *sql.DB, timeout time.Duration) error { + if db == nil { + return errors.New("ping postgres: nil db") + } + if timeout <= 0 { + return errors.New("ping postgres: timeout must be positive") + } + pingCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if err := db.PingContext(pingCtx); err != nil { + return fmt.Errorf("ping postgres: %w", err) + } + return nil +} diff --git a/backend/internal/server/middleware.go b/backend/internal/server/middleware.go new file mode 100644 index 0000000..d249b06 --- /dev/null +++ b/backend/internal/server/middleware.go @@ -0,0 +1,41 @@ +package server + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// headerUserID is the identity header the gateway injects after resolving a +// session to an internal account. +const headerUserID = "X-User-ID" + +// contextKey is an unexported type for request-context keys set by this package. +type contextKey string + +const userIDContextKey contextKey = "scrabble.user_id" + +// RequireUserID returns middleware that requires a valid X-User-ID header and +// stores the parsed account id in the request context. Requests without a +// parseable UUID are rejected with 401. The backend treats X-User-ID as the +// sole identity input and never derives identity from the request body. +func RequireUserID() gin.HandlerFunc { + return func(c *gin.Context) { + id, err := uuid.Parse(c.GetHeader(headerUserID)) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid X-User-ID"}) + return + } + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), userIDContextKey, id)) + c.Next() + } +} + +// UserIDFromContext returns the authenticated account id stored by +// RequireUserID, and whether it was present. +func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) { + id, ok := ctx.Value(userIDContextKey).(uuid.UUID) + return id, ok +} diff --git a/backend/internal/server/middleware_test.go b/backend/internal/server/middleware_test.go new file mode 100644 index 0000000..7a01b53 --- /dev/null +++ b/backend/internal/server/middleware_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// TestRequireUserID checks that the middleware accepts a valid X-User-ID, +// exposes it through the request context, and rejects missing or malformed +// headers with 401. +func TestRequireUserID(t *testing.T) { + gin.SetMode(gin.TestMode) + + var seen uuid.UUID + var ok bool + r := gin.New() + r.Use(RequireUserID()) + r.GET("/x", func(c *gin.Context) { + seen, ok = UserIDFromContext(c.Request.Context()) + c.String(http.StatusOK, "ok") + }) + + t.Run("valid", func(t *testing.T) { + seen, ok = uuid.Nil, false + id := uuid.New() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-User-ID", id.String()) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !ok || seen != id { + t.Fatalf("context id = %s (ok=%v), want %s", seen, ok, id) + } + }) + + t.Run("missing", func(t *testing.T) { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + }) + + t.Run("malformed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-User-ID", "not-a-uuid") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + }) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 8d10274..bae764d 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -1,53 +1,146 @@ // Package server wires the backend's HTTP listener: the gin engine, its route -// groups and the start/stop lifecycle. At this stage it serves only the -// infrastructure probes; the domain route groups described in PLAN.md -// (/api/v1/public, /user, /internal, /admin) are added by later stages. +// groups, the per-request telemetry middleware and the start/stop lifecycle. +// +// The /api/v1 route groups (public, user, internal, admin) are created here so +// later stages attach their endpoints to a stable structure; the /user group +// requires the X-User-ID identity header. The probes /healthz (liveness) and +// /readyz (database + session-cache readiness) are unauthenticated. package server import ( "context" + "database/sql" "errors" "net/http" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" + + "scrabble/backend/internal/telemetry" ) // shutdownTimeout bounds how long Run waits for in-flight requests to finish // during a graceful shutdown. const shutdownTimeout = 10 * time.Second -// Server owns the gin engine and the underlying HTTP server. -type Server struct { - log *zap.Logger - http *http.Server +// defaultPingTimeout bounds the /readyz database ping when Deps.PingTimeout is +// not set. +const defaultPingTimeout = 5 * time.Second + +// Deps carries the runtime dependencies the HTTP layer needs. +type Deps struct { + // Logger receives lifecycle, request and readiness diagnostics. + Logger *zap.Logger + // DB backs the /readyz database ping. A nil DB skips the database check. + DB *sql.DB + // PingTimeout bounds the /readyz database ping. + PingTimeout time.Duration + // SessionsReady reports whether the session cache has been warmed. A nil + // func skips the session-readiness check. + SessionsReady func() bool } -// New returns a Server that will listen on addr. The logger receives lifecycle -// and request diagnostics. -func New(addr string, log *zap.Logger) *Server { +// Server owns the gin engine, the underlying HTTP server and the readiness +// dependencies. +type Server struct { + log *zap.Logger + http *http.Server + db *sql.DB + pingTimeout time.Duration + sessionsReady func() bool + + public *gin.RouterGroup + user *gin.RouterGroup + internal *gin.RouterGroup + admin *gin.RouterGroup +} + +// New returns a Server that will listen on addr. It installs the recovery and +// telemetry middleware, the infrastructure probes, and the /api/v1 route groups. +func New(addr string, deps Deps) *Server { + log := deps.Logger + if log == nil { + log = zap.NewNop() + } + pingTimeout := deps.PingTimeout + if pingTimeout <= 0 { + pingTimeout = defaultPingTimeout + } + gin.SetMode(gin.ReleaseMode) engine := gin.New() engine.Use(gin.Recovery()) - registerProbes(engine) + engine.Use(telemetry.Middleware(log)) - return &Server{ - log: log, - http: &http.Server{Addr: addr, Handler: engine}, + s := &Server{ + log: log, + db: deps.DB, + pingTimeout: pingTimeout, + sessionsReady: deps.SessionsReady, + http: &http.Server{Addr: addr, Handler: engine}, } + s.registerProbes(engine) + s.registerAPIGroups(engine) + return s } // registerProbes installs the unauthenticated infrastructure probes: /healthz -// reports process liveness and /readyz reports readiness to serve traffic. -// Until later stages add real dependencies (Postgres, warmed caches), -// readiness mirrors liveness. -func registerProbes(engine *gin.Engine) { - ok := func(c *gin.Context) { c.String(http.StatusOK, "ok") } - engine.GET("/healthz", ok) - engine.GET("/readyz", ok) +// reports process liveness and /readyz reports readiness to serve traffic +// (database reachable and session cache warmed). +func (s *Server) registerProbes(engine *gin.Engine) { + engine.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + engine.GET("/readyz", s.readyz) } +// readyz reports 200 only when the database answers a bounded ping and the +// session cache is warmed; otherwise 503. +func (s *Server) readyz(c *gin.Context) { + if s.db != nil { + ctx, cancel := context.WithTimeout(c.Request.Context(), s.pingTimeout) + defer cancel() + if err := s.db.PingContext(ctx); err != nil { + s.log.Warn("readiness: database ping failed", zap.Error(err)) + c.String(http.StatusServiceUnavailable, "database unavailable") + return + } + } + if s.sessionsReady != nil && !s.sessionsReady() { + c.String(http.StatusServiceUnavailable, "sessions not ready") + return + } + c.String(http.StatusOK, "ok") +} + +// registerAPIGroups wires the /api/v1 route groups. They are populated by the +// stages that add their first endpoint; the /user group requires X-User-ID, +// which the gateway injects after resolving a session. +func (s *Server) registerAPIGroups(engine *gin.Engine) { + v1 := engine.Group("/api/v1") + s.public = v1.Group("/public") + s.user = v1.Group("/user") + s.user.Use(RequireUserID()) + s.internal = v1.Group("/internal") + s.admin = v1.Group("/admin") +} + +// PublicGroup returns the unauthenticated public route group. +func (s *Server) PublicGroup() *gin.RouterGroup { return s.public } + +// UserGroup returns the authenticated user route group (requires X-User-ID). +func (s *Server) UserGroup() *gin.RouterGroup { return s.user } + +// InternalGroup returns the gateway-facing internal route group. +func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal } + +// AdminGroup returns the admin route group (authenticated at the gateway). +func (s *Server) AdminGroup() *gin.RouterGroup { return s.admin } + +// Handler returns the underlying HTTP handler. It lets tests drive the server +// without binding a socket and lets later stages compose the backend behind +// another listener. +func (s *Server) Handler() http.Handler { return s.http.Handler } + // Run starts the listener and blocks until ctx is cancelled, then shuts the // server down gracefully within shutdownTimeout. It returns the first error // that is not the expected http.ErrServerClosed. diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index df438ec..30e03fd 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -8,15 +8,51 @@ import ( "go.uber.org/zap/zaptest" ) -// TestProbes verifies that the infrastructure probes answer 200 OK. -func TestProbes(t *testing.T) { - srv := New(":0", zaptest.NewLogger(t)) - for _, path := range []string{"/healthz", "/readyz"} { - req := httptest.NewRequest(http.MethodGet, path, nil) - rec := httptest.NewRecorder() - srv.http.Handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("%s: status = %d, want %d", path, rec.Code, http.StatusOK) - } +// get serves a GET request against the server's handler and returns the recorder. +func get(srv *Server, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec +} + +// TestHealthz verifies that /healthz answers 200 OK. +func TestHealthz(t *testing.T) { + srv := New(":0", Deps{Logger: zaptest.NewLogger(t)}) + if rec := get(srv, "/healthz"); rec.Code != http.StatusOK { + t.Fatalf("/healthz status = %d, want %d", rec.Code, http.StatusOK) + } +} + +// TestReadyzReadyWithoutDeps verifies that, with no database and no session +// readiness gate wired, /readyz answers 200 OK. +func TestReadyzReadyWithoutDeps(t *testing.T) { + srv := New(":0", Deps{Logger: zaptest.NewLogger(t)}) + if rec := get(srv, "/readyz"); rec.Code != http.StatusOK { + t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusOK) + } +} + +// TestReadyzNotReadyWhenSessionsCold verifies that /readyz answers 503 while the +// session cache reports not-ready. +func TestReadyzNotReadyWhenSessionsCold(t *testing.T) { + srv := New(":0", Deps{ + Logger: zaptest.NewLogger(t), + SessionsReady: func() bool { return false }, + }) + if rec := get(srv, "/readyz"); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } +} + +// TestReadyzReadyWhenSessionsWarm verifies that /readyz answers 200 once the +// session cache reports ready (and no database is wired). +func TestReadyzReadyWhenSessionsWarm(t *testing.T) { + srv := New(":0", Deps{ + Logger: zaptest.NewLogger(t), + SessionsReady: func() bool { return true }, + }) + if rec := get(srv, "/readyz"); rec.Code != http.StatusOK { + t.Fatalf("/readyz status = %d, want %d", rec.Code, http.StatusOK) } } diff --git a/backend/internal/session/cache.go b/backend/internal/session/cache.go new file mode 100644 index 0000000..39739c4 --- /dev/null +++ b/backend/internal/session/cache.go @@ -0,0 +1,95 @@ +package session + +import ( + "context" + "sync" + "sync/atomic" +) + +// Cache is the in-memory write-through projection of the active rows in +// backend.sessions, keyed by token hash so Resolve avoids a database round-trip +// on the hot path. Reads are RLocked; writes are Locked. Callers commit the +// corresponding database write before invoking Add or Remove so the cache stays +// consistent with the persisted state. +type Cache struct { + mu sync.RWMutex + byHash map[string]Session + ready atomic.Bool +} + +// NewCache constructs an empty Cache. It reports Ready() == false until Warm +// completes successfully. +func NewCache() *Cache { + return &Cache{byHash: make(map[string]Session)} +} + +// Warm replaces the cache contents with every active session loaded from store. +// It is intended to run once at process boot before the listener accepts +// traffic; success flips Ready to true. Re-warming is supported (useful in +// tests). +func (c *Cache) Warm(ctx context.Context, store *Store) error { + sessions, err := store.ListActive(ctx) + if err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + c.byHash = make(map[string]Session, len(sessions)) + for _, s := range sessions { + c.byHash[s.TokenHash] = s + } + c.ready.Store(true) + return nil +} + +// Ready reports whether Warm has completed at least once. The /readyz probe +// wires through this so the backend only reports ready once sessions are +// hydrated. +func (c *Cache) Ready() bool { + if c == nil { + return false + } + return c.ready.Load() +} + +// Size returns the number of cached active sessions, for startup logs and tests. +func (c *Cache) Size() int { + if c == nil { + return 0 + } + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.byHash) +} + +// Get returns the session for tokenHash and a presence flag. A miss returns the +// zero Session and false. +func (c *Cache) Get(tokenHash string) (Session, bool) { + if c == nil { + return Session{}, false + } + c.mu.RLock() + defer c.mu.RUnlock() + s, ok := c.byHash[tokenHash] + return s, ok +} + +// Add stores s under its token hash. It is safe to call on an existing entry. +func (c *Cache) Add(s Session) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.byHash[s.TokenHash] = s +} + +// Remove evicts the entry for tokenHash. Removing a missing entry is a no-op. +func (c *Cache) Remove(tokenHash string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + delete(c.byHash, tokenHash) +} diff --git a/backend/internal/session/cache_test.go b/backend/internal/session/cache_test.go new file mode 100644 index 0000000..4fcf630 --- /dev/null +++ b/backend/internal/session/cache_test.go @@ -0,0 +1,52 @@ +package session + +import ( + "testing" + + "github.com/google/uuid" +) + +// TestCache exercises the write-through cache's add/get/remove/size/ready cycle. +func TestCache(t *testing.T) { + c := NewCache() + if c.Ready() { + t.Error("a fresh cache must not report ready") + } + if _, ok := c.Get("h1"); ok { + t.Error("get on empty cache must miss") + } + + s := Session{ID: uuid.New(), AccountID: uuid.New(), TokenHash: "h1", Status: StatusActive} + c.Add(s) + if got, ok := c.Get("h1"); !ok || got.ID != s.ID { + t.Fatalf("get after add: got %v ok=%v", got.ID, ok) + } + if c.Size() != 1 { + t.Errorf("size = %d, want 1", c.Size()) + } + + c.Remove("h1") + if _, ok := c.Get("h1"); ok { + t.Error("get after remove must miss") + } + if c.Size() != 0 { + t.Errorf("size = %d, want 0", c.Size()) + } +} + +// TestCacheNilSafe checks that the cache methods are safe on a nil receiver, +// which the readiness probe relies on before the cache is constructed. +func TestCacheNilSafe(t *testing.T) { + var c *Cache + if c.Ready() { + t.Error("nil cache must not be ready") + } + if _, ok := c.Get("x"); ok { + t.Error("nil cache get must miss") + } + if c.Size() != 0 { + t.Error("nil cache size must be 0") + } + c.Add(Session{TokenHash: "x"}) // must not panic + c.Remove("x") // must not panic +} diff --git a/backend/internal/session/service.go b/backend/internal/session/service.go new file mode 100644 index 0000000..47d0f9e --- /dev/null +++ b/backend/internal/session/service.go @@ -0,0 +1,73 @@ +package session + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// Service mints, resolves, and revokes sessions over the store and the +// write-through cache. The gateway is its only caller (from a later stage); the +// HTTP surface is wired then. +type Service struct { + store *Store + cache *Cache +} + +// NewService constructs a Service over store and cache. +func NewService(store *Store, cache *Cache) *Service { + return &Service{store: store, cache: cache} +} + +// Warm hydrates the cache from the store. Call once before serving traffic. +func (svc *Service) Warm(ctx context.Context) error { + return svc.cache.Warm(ctx, svc.store) +} + +// Ready reports whether the session cache has been warmed. +func (svc *Service) Ready() bool { + return svc.cache.Ready() +} + +// Create mints a new active session for accountID and returns the plaintext +// token (shown to the caller once) together with the persisted session. +func (svc *Service) Create(ctx context.Context, accountID uuid.UUID) (string, Session, error) { + token, tokenHash, err := GenerateToken() + if err != nil { + return "", Session{}, err + } + sess, err := svc.store.Insert(ctx, accountID, tokenHash) + if err != nil { + return "", Session{}, err + } + svc.cache.Add(sess) + return token, sess, nil +} + +// Resolve maps a presented token to its active session, consulting the cache +// first and falling back to the store (repopulating the cache on a hit). +// Returns ErrNotFound when no active session matches. +func (svc *Service) Resolve(ctx context.Context, token string) (Session, error) { + hash := HashToken(token) + if sess, ok := svc.cache.Get(hash); ok { + return sess, nil + } + sess, err := svc.store.FindActiveByTokenHash(ctx, hash) + if err != nil { + return Session{}, err + } + svc.cache.Add(sess) + return sess, nil +} + +// Revoke revokes the session for the presented token. It is idempotent: +// revoking an unknown or already-revoked token returns nil. +func (svc *Service) Revoke(ctx context.Context, token string) error { + hash := HashToken(token) + if _, _, err := svc.store.RevokeByTokenHash(ctx, hash, time.Now().UTC()); err != nil { + return err + } + svc.cache.Remove(hash) + return nil +} diff --git a/backend/internal/session/store.go b/backend/internal/session/store.go new file mode 100644 index 0000000..51ef935 --- /dev/null +++ b/backend/internal/session/store.go @@ -0,0 +1,149 @@ +package session + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Session lifecycle statuses persisted in the status column. +const ( + StatusActive = "active" + StatusRevoked = "revoked" +) + +// ErrNotFound is returned when no active session matches the lookup. +var ErrNotFound = errors.New("session: not found") + +// Session mirrors a row in backend.sessions. TokenHash is the hex-encoded +// SHA-256 of the bearer token. +type Session struct { + ID uuid.UUID + AccountID uuid.UUID + TokenHash string + Status string + CreatedAt time.Time + LastSeenAt *time.Time + RevokedAt *time.Time +} + +// Store is the Postgres-backed query surface for backend.sessions. +type Store struct { + db *sql.DB +} + +// NewStore constructs a Store wrapping db. +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// Insert persists a new active session for accountID carrying tokenHash and +// returns the persisted row. +func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string) (Session, error) { + id, err := uuid.NewV7() + if err != nil { + return Session{}, fmt.Errorf("session: new id: %w", err) + } + stmt := table.Sessions.INSERT( + table.Sessions.SessionID, + table.Sessions.AccountID, + table.Sessions.TokenHash, + ).VALUES(id, accountID, tokenHash).RETURNING(table.Sessions.AllColumns) + + var row model.Sessions + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Session{}, fmt.Errorf("session: insert: %w", err) + } + return modelToSession(row), nil +} + +// FindActiveByTokenHash returns the active session matching tokenHash, or +// ErrNotFound. +func (s *Store) FindActiveByTokenHash(ctx context.Context, tokenHash string) (Session, error) { + stmt := postgres.SELECT(table.Sessions.AllColumns). + FROM(table.Sessions). + WHERE( + table.Sessions.TokenHash.EQ(postgres.String(tokenHash)). + AND(table.Sessions.Status.EQ(postgres.String(StatusActive))), + ). + LIMIT(1) + + var row model.Sessions + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Session{}, ErrNotFound + } + return Session{}, fmt.Errorf("session: find by token hash: %w", err) + } + return modelToSession(row), nil +} + +// RevokeByTokenHash transitions the active session for tokenHash to revoked and +// returns the post-update row. ok is false with a nil error when no active +// session matched, so revocation is idempotent. +func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time.Time) (Session, bool, error) { + stmt := table.Sessions. + UPDATE(table.Sessions.Status, table.Sessions.RevokedAt). + SET(postgres.String(StatusRevoked), postgres.TimestampzT(at)). + WHERE( + table.Sessions.TokenHash.EQ(postgres.String(tokenHash)). + AND(table.Sessions.Status.EQ(postgres.String(StatusActive))), + ). + RETURNING(table.Sessions.AllColumns) + + var row model.Sessions + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Session{}, false, nil + } + return Session{}, false, fmt.Errorf("session: revoke by token hash: %w", err) + } + return modelToSession(row), true, nil +} + +// ListActive loads every active session. Cache.Warm calls this at boot. +func (s *Store) ListActive(ctx context.Context) ([]Session, error) { + stmt := postgres.SELECT(table.Sessions.AllColumns). + FROM(table.Sessions). + WHERE(table.Sessions.Status.EQ(postgres.String(StatusActive))) + + var rows []model.Sessions + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("session: list active: %w", err) + } + out := make([]Session, 0, len(rows)) + for _, row := range rows { + out = append(out, modelToSession(row)) + } + return out, nil +} + +// modelToSession projects a generated model row into the public Session struct, +// copying pointer fields so callers cannot mutate the scan buffer. +func modelToSession(row model.Sessions) Session { + s := Session{ + ID: row.SessionID, + AccountID: row.AccountID, + TokenHash: row.TokenHash, + Status: row.Status, + CreatedAt: row.CreatedAt, + } + if row.LastSeenAt != nil { + t := *row.LastSeenAt + s.LastSeenAt = &t + } + if row.RevokedAt != nil { + t := *row.RevokedAt + s.RevokedAt = &t + } + return s +} diff --git a/backend/internal/session/token.go b/backend/internal/session/token.go new file mode 100644 index 0000000..ed7f5ce --- /dev/null +++ b/backend/internal/session/token.go @@ -0,0 +1,35 @@ +// Package session owns opaque server sessions: minting bearer tokens, resolving +// them to accounts through a write-through in-memory cache, and revoking them. +// Only the SHA-256 hash of a token is persisted; the plaintext is returned to +// the caller once and never stored. Sessions are revoke-only (no TTL). +package session + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" +) + +// tokenBytes is the entropy of an opaque session token: 256 bits. +const tokenBytes = 32 + +// GenerateToken returns a fresh random opaque token (URL-safe base64, 256-bit) +// together with its hex-encoded SHA-256 hash for storage. The plaintext token +// is handed to the caller once and is never persisted. +func GenerateToken() (token, tokenHash string, err error) { + buf := make([]byte, tokenBytes) + if _, err := rand.Read(buf); err != nil { + return "", "", fmt.Errorf("session: read random token: %w", err) + } + token = base64.RawURLEncoding.EncodeToString(buf) + return token, HashToken(token), nil +} + +// HashToken returns the hex-encoded SHA-256 of token. Lookups hash the presented +// token and compare against the stored hash. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/backend/internal/session/token_test.go b/backend/internal/session/token_test.go new file mode 100644 index 0000000..c39f401 --- /dev/null +++ b/backend/internal/session/token_test.go @@ -0,0 +1,44 @@ +package session + +import "testing" + +// TestGenerateTokenUniqueAndHashed checks that tokens are unique, the stored +// value is the hash (not the plaintext), and the hash is a 64-char SHA-256 hex. +func TestGenerateTokenUniqueAndHashed(t *testing.T) { + tok1, hash1, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + tok2, hash2, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + if tok1 == tok2 { + t.Error("tokens must be unique") + } + if hash1 == hash2 { + t.Error("hashes must differ for distinct tokens") + } + if hash1 != HashToken(tok1) { + t.Error("stored hash must equal HashToken(token)") + } + if tok1 == hash1 { + t.Error("stored hash must not equal the plaintext token") + } + if len(hash1) != 64 { + t.Errorf("hash length = %d, want 64 (sha256 hex)", len(hash1)) + } +} + +// TestHashTokenDeterministic checks that hashing is stable for a given token. +func TestHashTokenDeterministic(t *testing.T) { + first := HashToken("alpha") + second := HashToken("alpha") + if first != second { + t.Error("HashToken must be deterministic") + } + if first == HashToken("beta") { + t.Error("distinct tokens must hash differently") + } +} diff --git a/backend/internal/telemetry/middleware.go b/backend/internal/telemetry/middleware.go new file mode 100644 index 0000000..4485a5a --- /dev/null +++ b/backend/internal/telemetry/middleware.go @@ -0,0 +1,74 @@ +package telemetry + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.uber.org/zap" +) + +// tracerName names the instrumentation scope for backend HTTP spans. +const tracerName = "scrabble/backend/server" + +// Middleware returns gin middleware that, for every request, opens a server +// span, measures server-side latency, and emits a structured access log +// correlated with the active trace. It uses the globally-registered tracer, so +// spans are exported only when an exporter is configured, while the timing log +// is always emitted. Probe paths (/healthz, /readyz) log at debug level to keep +// the default log clean. +func Middleware(logger *zap.Logger) gin.HandlerFunc { + if logger == nil { + logger = zap.NewNop() + } + tracer := otel.Tracer(tracerName) + + return func(c *gin.Context) { + start := time.Now() + route := c.FullPath() + if route == "" { + route = c.Request.URL.Path + } + + ctx, span := tracer.Start(c.Request.Context(), c.Request.Method+" "+route) + c.Request = c.Request.WithContext(ctx) + + c.Next() + + status := c.Writer.Status() + elapsed := time.Since(start) + + span.SetAttributes( + attribute.String("http.request.method", c.Request.Method), + attribute.String("http.route", route), + attribute.Int("http.response.status_code", status), + ) + if status >= http.StatusInternalServerError { + span.SetStatus(codes.Error, http.StatusText(status)) + } + span.End() + + fields := []zap.Field{ + zap.String("method", c.Request.Method), + zap.String("path", route), + zap.Int("status", status), + zap.Duration("latency", elapsed), + } + fields = append(fields, TraceFieldsFromContext(ctx)...) + + if isProbePath(c.Request.URL.Path) { + logger.Debug("http request", fields...) + return + } + logger.Info("http request", fields...) + } +} + +// isProbePath reports whether path is one of the unauthenticated infrastructure +// probes, whose access logs are demoted to debug level. +func isProbePath(path string) bool { + return path == "/healthz" || path == "/readyz" +} diff --git a/backend/internal/telemetry/telemetry.go b/backend/internal/telemetry/telemetry.go new file mode 100644 index 0000000..49a59af --- /dev/null +++ b/backend/internal/telemetry/telemetry.go @@ -0,0 +1,204 @@ +// Package telemetry owns the OpenTelemetry runtime for the backend process. +// +// New constructs the configured tracer and meter providers, registers them as +// the OpenTelemetry globals, and exposes Shutdown for orderly exit. The MVP +// supports the `none` and `stdout` exporters; OTLP export and dashboards arrive +// in a later stage. The per-request timing middleware lives in middleware.go and +// uses the registered global tracer, so requests are timed and logged even when +// the exporter is `none`. +package telemetry + +import ( + "context" + "errors" + "fmt" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" +) + +// Exporter selectors supported by the backend. +const ( + ExporterNone = "none" + ExporterStdout = "stdout" +) + +// DefaultServiceName labels traces and metrics when BACKEND_SERVICE_NAME is +// unset. +const DefaultServiceName = "scrabble-backend" + +// Config selects the telemetry providers' service name and exporters. +type Config struct { + // ServiceName is reported as the OpenTelemetry service.name resource. + ServiceName string + // TracesExporter is one of ExporterNone or ExporterStdout. + TracesExporter string + // MetricsExporter is one of ExporterNone or ExporterStdout. + MetricsExporter string +} + +// DefaultConfig returns the MVP telemetry configuration: named service, no +// exporters (so no collector is required locally or in CI). +func DefaultConfig() Config { + return Config{ + ServiceName: DefaultServiceName, + TracesExporter: ExporterNone, + MetricsExporter: ExporterNone, + } +} + +// Validate reports whether the configuration selects supported exporters. +func (c Config) Validate() error { + if c.ServiceName == "" { + return errors.New("telemetry: ServiceName must not be empty") + } + if err := validateExporter("traces", c.TracesExporter); err != nil { + return err + } + return validateExporter("metrics", c.MetricsExporter) +} + +func validateExporter(kind, value string) error { + switch value { + case ExporterNone, ExporterStdout: + return nil + default: + return fmt.Errorf("telemetry: unsupported %s exporter %q", kind, value) + } +} + +// Runtime owns the shared OpenTelemetry providers. +type Runtime struct { + tracerProvider *sdktrace.TracerProvider + meterProvider *sdkmetric.MeterProvider +} + +// New constructs the telemetry runtime, registers the global providers and the +// W3C trace-context/baggage propagators, and returns the Runtime. Callers must +// invoke Runtime.Shutdown during process exit. +func New(ctx context.Context, cfg Config) (*Runtime, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + res, err := resource.New(ctx, resource.WithAttributes( + attribute.String("service.name", cfg.ServiceName), + )) + if err != nil { + return nil, fmt.Errorf("telemetry: build resource: %w", err) + } + + tracerProvider, err := newTracerProvider(cfg, res) + if err != nil { + return nil, fmt.Errorf("telemetry: build tracer provider: %w", err) + } + meterProvider, err := newMeterProvider(cfg, res) + if err != nil { + _ = tracerProvider.Shutdown(ctx) + return nil, fmt.Errorf("telemetry: build meter provider: %w", err) + } + + otel.SetTracerProvider(tracerProvider) + otel.SetMeterProvider(meterProvider) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return &Runtime{tracerProvider: tracerProvider, meterProvider: meterProvider}, nil +} + +// TracerProvider returns the runtime tracer provider, or the global one when r +// is not initialised. +func (r *Runtime) TracerProvider() trace.TracerProvider { + if r == nil || r.tracerProvider == nil { + return otel.GetTracerProvider() + } + return r.tracerProvider +} + +// MeterProvider returns the runtime meter provider, or the global one when r is +// not initialised. +func (r *Runtime) MeterProvider() metric.MeterProvider { + if r == nil || r.meterProvider == nil { + return otel.GetMeterProvider() + } + return r.meterProvider +} + +// Shutdown flushes both providers within ctx. +func (r *Runtime) Shutdown(ctx context.Context) error { + if r == nil { + return nil + } + var err error + if r.meterProvider != nil { + err = errors.Join(err, r.meterProvider.Shutdown(ctx)) + } + if r.tracerProvider != nil { + err = errors.Join(err, r.tracerProvider.Shutdown(ctx)) + } + return err +} + +// TraceFieldsFromContext returns zap fields identifying the active span, or nil +// when ctx carries no valid span context. Collocated here so callers do not +// import the OpenTelemetry API directly. +func TraceFieldsFromContext(ctx context.Context) []zap.Field { + if ctx == nil { + return nil + } + sc := trace.SpanContextFromContext(ctx) + if !sc.IsValid() { + return nil + } + return []zap.Field{ + zap.String("otel_trace_id", sc.TraceID().String()), + zap.String("otel_span_id", sc.SpanID().String()), + } +} + +func newTracerProvider(cfg Config, res *resource.Resource) (*sdktrace.TracerProvider, error) { + switch cfg.TracesExporter { + case ExporterNone: + return sdktrace.NewTracerProvider(sdktrace.WithResource(res)), nil + case ExporterStdout: + exporter, err := stdouttrace.New() + if err != nil { + return nil, fmt.Errorf("stdout trace exporter: %w", err) + } + return sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ), nil + default: + return nil, fmt.Errorf("unsupported traces exporter %q", cfg.TracesExporter) + } +} + +func newMeterProvider(cfg Config, res *resource.Resource) (*sdkmetric.MeterProvider, error) { + switch cfg.MetricsExporter { + case ExporterNone: + return sdkmetric.NewMeterProvider(sdkmetric.WithResource(res)), nil + case ExporterStdout: + exporter, err := stdoutmetric.New() + if err != nil { + return nil, fmt.Errorf("stdout metric exporter: %w", err) + } + return sdkmetric.NewMeterProvider( + sdkmetric.WithResource(res), + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)), + ), nil + default: + return nil, fmt.Errorf("unsupported metrics exporter %q", cfg.MetricsExporter) + } +} diff --git a/backend/internal/telemetry/telemetry_test.go b/backend/internal/telemetry/telemetry_test.go new file mode 100644 index 0000000..1c16d5f --- /dev/null +++ b/backend/internal/telemetry/telemetry_test.go @@ -0,0 +1,82 @@ +package telemetry + +import ( + "context" + "testing" +) + +// TestConfigValidate covers the supported and rejected exporter selections. +func TestConfigValidate(t *testing.T) { + if err := DefaultConfig().Validate(); err != nil { + t.Fatalf("default config must be valid: %v", err) + } + + otlp := DefaultConfig() + otlp.TracesExporter = "otlp" + if err := otlp.Validate(); err == nil { + t.Error("otlp exporter must be rejected in the MVP set") + } + + noName := DefaultConfig() + noName.ServiceName = "" + if err := noName.Validate(); err == nil { + t.Error("empty service name must be rejected") + } +} + +// TestNewNoneAndShutdown builds the providers with the none exporter and shuts +// them down. +func TestNewNoneAndShutdown(t *testing.T) { + rt, err := New(context.Background(), DefaultConfig()) + if err != nil { + t.Fatalf("New: %v", err) + } + if rt.TracerProvider() == nil { + t.Error("TracerProvider must not be nil") + } + if rt.MeterProvider() == nil { + t.Error("MeterProvider must not be nil") + } + if err := rt.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown: %v", err) + } +} + +// TestNewStdoutTraces builds the providers with the stdout trace exporter; no +// spans are recorded, so shutdown flushes nothing to stdout. +func TestNewStdoutTraces(t *testing.T) { + cfg := DefaultConfig() + cfg.TracesExporter = ExporterStdout + rt, err := New(context.Background(), cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := rt.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown: %v", err) + } +} + +// TestTraceFieldsFromContextEmpty returns no fields without an active span. +func TestTraceFieldsFromContextEmpty(t *testing.T) { + if TraceFieldsFromContext(context.Background()) != nil { + t.Error("expected nil fields without an active span") + } + var nilCtx context.Context + if TraceFieldsFromContext(nilCtx) != nil { + t.Error("expected nil fields for a nil context") + } +} + +// TestNilRuntime checks the nil-receiver fallbacks used before initialisation. +func TestNilRuntime(t *testing.T) { + var rt *Runtime + if rt.TracerProvider() == nil { + t.Error("nil runtime must fall back to the global tracer provider") + } + if rt.MeterProvider() == nil { + t.Error("nil runtime must fall back to the global meter provider") + } + if err := rt.Shutdown(context.Background()); err != nil { + t.Errorf("nil runtime Shutdown: %v", err) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cb4c6fa..053a14d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -71,8 +71,11 @@ arrive from a platform rather than completing a mandatory registration). (`session_id`). - The client holds `session_id` in memory for the app session (browser/OS storage is optional and may be unavailable; losing it means re-login). -- The gateway caches `session → user_id` and injects `X-User-ID`. Sessions are - revocable. Session records live in `backend`. +- The gateway caches `session → user_id` and injects `X-User-ID`. Session + records live in `backend`, which stores only a **SHA-256 hash** of the opaque + token (never the plaintext), keeps a warmed in-memory cache for fast + resolution, and treats sessions as **revoke-only** — they have no TTL and live + until explicitly revoked (`status` → `revoked`). - **Guest** = ephemeral web session (no platform, no email): session-only, nothing persisted; restricted to auto-match, with no friends and no stats/history. Platform users are auto-provisioned **durable** accounts. @@ -82,6 +85,10 @@ arrive from a platform rather than completing a mandatory registration). - One internal account may carry several **platform identities** (`telegram`, `vk`, …) plus an optional **email** identity. First contact from a platform auto-provisions a durable account bound to that platform identity. + Concretely, platform and email identities share one `identities` table keyed by + a unique `(kind, external_id)`; email is an identity with `kind=email` and a + `confirmed` flag (the confirm-code flow lands later). Accounts and identities + use application-generated **UUIDv7** primary keys. - **Linking** is initiated from an authenticated profile: choose a platform → complete that platform's web-auth confirm → attach the identity to the current account. @@ -155,9 +162,15 @@ within 10 seconds. Designed to be indistinguishable from a person. ## 9. Persistence -- Single Postgres database, schema `backend`; `backend` is the only writer. - pgx pool; queries via go-jet *(introduced when the first real query lands)*; - migrations embedded and applied with `pressly/goose/v3` at startup *(planned)*. +- Single Postgres database, schema `backend`; `backend` is the only writer. The + "pgx pool" is a `database/sql` handle backed by the pgx stdlib driver and + instrumented with otelsql; type-safe queries use **go-jet** (code generated + into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`). + Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary + keys are application-generated **UUIDv7**. +- Stage 1 tables: `accounts` (durable internal accounts), `identities` + (platform/email identities, unique `(kind, external_id)`) and `sessions` + (revoke-only opaque-token hashes). - **Active game state** is stored structurally with the `dict_version` pinned. - **Statistics** (computed on finish): wins, losses, max points in a game, max points for a single word. @@ -185,12 +198,17 @@ delivery fans out to the appropriate channel. ## 11. Observability -- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry traces and - metrics, with a Prometheus pull endpoint where configured *(introduced with - the first real workload)*. -- Per-request server-side timing via middleware from day one. A client-measured - RTT piggybacked on the next request is a later enhancement, not MVP. -- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness). +- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and + meter providers are wired (Stage 1), env-gated by + `BACKEND_OTEL_{TRACES,METRICS}_EXPORTER` with a default of `none` (so no + collector is required locally or in CI); `stdout` is available for debugging + and the Postgres pool is instrumented with otelsql. OTLP export, a Prometheus + pull endpoint, and dashboards arrive with the first real workload. +- Per-request server-side timing via gin middleware from day one (the access log + carries method, route, status, latency and the active trace id). A + client-measured RTT piggybacked on the next request is a later enhancement. +- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the + database answers a bounded ping and the session cache is warmed). ## 12. Security boundaries @@ -219,8 +237,10 @@ is something to deploy. - Trunk is **`master`**; feature work happens on `feature/*` branches merged via PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily lands on `master`). -- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/test on - Go changes; more workflows (ui-test, integration, deploy) are added with the - components they cover. +- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test + on Go changes; `integration.yaml` runs the Postgres-backed tests behind the + `integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled, + serial). Further workflows (ui-test, deploy) are added with the components they + cover. - After any push, the run is watched to green before a stage is declared done (`python3 ~/.claude/bin/gitea-ci-watch.py`). diff --git a/docs/TESTING.md b/docs/TESTING.md index 138794a..821a954 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -8,8 +8,11 @@ tests or touching CI. - **Go unit tests** — table-driven where it helps; `testing` + standard library. Every functional change ships with regression coverage. Run: `go test -count=1 ./backend/...` (the module list grows with the workspace). -- **Integration** *(introduced with Postgres in Stage 1)* — `testcontainers-go` - spins real dependencies (Postgres). Slow; a separate CI workflow. +- **Integration** *(Stage 1+)* — Postgres-backed tests behind the `integration` + build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They + live in `backend/internal/inttest` and run with + `go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded + by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow. - **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright (e2e), mirroring the chosen plain-Svelte + Vite toolchain. - **Engine** — correctness of scoring and move generation is owned by diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..465e4a0 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,69 @@ +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= +github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= +github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= +github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= +github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus= +github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= +github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= +github.com/vertica/vertica-sql-go v1.3.6/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/volatiletech/inflect v0.0.1/go.mod h1:IBti31tG6phkHitLlr5j7shC5SOo//x0AjDzaJU1PLA= +github.com/volatiletech/null/v8 v8.1.2/go.mod h1:98DbwNoKEpRrYtGjWFctievIfm4n4MxG0A6EBUcoS5g= +github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY= +github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+58Fa2g8ccAqdmaaK6HP5k= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=