From 69fa6b30e1db7600f6a46952bde1577d4d1a4e68 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 8 May 2026 09:42:29 +0200 Subject: [PATCH] tools/local-dev: docker-compose stack for UI development Adds tools/local-dev/ with postgres + redis + mailpit + backend + gateway plus a Make wrapper, so `make -C tools/local-dev up` brings the full authenticated stack online and `pnpm -C ui/frontend dev` talks to it directly. The committed `.env.development` already points at the stack and pins the matching gateway response public key from the dev keypair under tools/local-dev/keys/. The backend ships a new opt-in env, BACKEND_AUTH_DEV_FIXED_CODE (`tools/local-dev/.env` defaults it to 123456). When set, ConfirmEmailCode accepts that literal in addition to the real bcrypt-verified code; SendEmailCode still queues a real email so Mailpit captures the issued code at http://localhost:8025/, and both paths coexist. The override is rejected as non-six-digit by config validation and emits a loud warning at backend startup. The local-dev Dockerfiles mirror backend/Dockerfile and gateway/Dockerfile but switch the runtime stage to alpine so docker-compose healthchecks can wget /healthz; the gateway Dockerfile additionally copies ui/core/ into the build context because gateway/go.mod's `replace galaxy/core => ../ui/core` is required to compile the gateway main. Smoke tested: - `make -C tools/local-dev up` boots all five services to healthy. - send-email-code + confirm-email-code with code=123456 returns a device_session_id; a real code in Mailpit also redeems successfully. - `pnpm test` 14/14, `pnpm exec playwright test` 44/44. - `go test ./backend/internal/config/...` green. Docs: tools/local-dev/README.md, tools/local-dev/keys/README.md, new "Local development stack" section in ui/docs/testing.md, and a short pointer in ui/README.md. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 7 + backend/internal/auth/auth.go | 21 +++ backend/internal/auth/auth_e2e_test.go | 78 +++++++++ backend/internal/auth/challenge.go | 22 ++- backend/internal/config/config.go | 29 ++++ backend/internal/config/config_test.go | 34 ++++ tools/local-dev/.env | 8 + tools/local-dev/Makefile | 53 ++++++ tools/local-dev/README.md | 161 +++++++++++++++++++ tools/local-dev/backend.Dockerfile | 68 ++++++++ tools/local-dev/docker-compose.yml | 186 ++++++++++++++++++++++ tools/local-dev/gateway.Dockerfile | 74 +++++++++ tools/local-dev/keys/README.md | 34 ++++ tools/local-dev/keys/gateway-response.pem | 3 + tools/local-dev/keys/gateway-response.pub | 4 + tools/local-dev/keys/regenerate.go | 47 ++++++ ui/README.md | 18 +++ ui/docs/testing.md | 26 +++ ui/frontend/.env.development | 12 ++ ui/frontend/.env.example | 21 ++- 20 files changed, 887 insertions(+), 19 deletions(-) create mode 100644 tools/local-dev/.env create mode 100644 tools/local-dev/Makefile create mode 100644 tools/local-dev/README.md create mode 100644 tools/local-dev/backend.Dockerfile create mode 100644 tools/local-dev/docker-compose.yml create mode 100644 tools/local-dev/gateway.Dockerfile create mode 100644 tools/local-dev/keys/README.md create mode 100644 tools/local-dev/keys/gateway-response.pem create mode 100644 tools/local-dev/keys/gateway-response.pub create mode 100644 tools/local-dev/keys/regenerate.go create mode 100644 ui/frontend/.env.development diff --git a/.gitignore b/.gitignore index 9b4d6a6..3393498 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ .codex .vscode/ artifacts/.claude/scheduled_tasks.lock + +# Per-developer Vite dotenv overrides. The committed +# `ui/frontend/.env.development` ships sane defaults for the +# `tools/local-dev/` stack; `.local` siblings stay personal and +# unstaged. +**/.env.local +**/.env.*.local diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index e0c1b09..66a0ac3 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -76,9 +76,30 @@ func NewService(deps Deps) *Service { // not a security primitive, so a constant key is acceptable. copy(key, []byte("galaxy-backend-auth-fallback-key")) } + if deps.Config.DevFixedCode != "" { + // Loud, repeated warning so a stray production deployment cannot + // claim the operator was unaware. The override is intended for + // `tools/local-dev/` and never reaches production binaries in + // normal operation. + deps.Logger.Warn("DEV-MODE: BACKEND_AUTH_DEV_FIXED_CODE is set; ConfirmEmailCode accepts the literal code in addition to the bcrypt-verified one. NEVER use in production.") + } return &Service{deps: deps, emailHashKey: key} } +// devFixedCodeMatches reports whether the dev-mode fixed-code override +// is configured and the submitted code matches it verbatim. The +// override is opt-in via `BACKEND_AUTH_DEV_FIXED_CODE`; production +// deployments leave the field empty and devFixedCodeMatches always +// returns false. See `tools/local-dev/README.md` for the full +// rationale. +func (s *Service) devFixedCodeMatches(code string) bool { + fixed := s.deps.Config.DevFixedCode + if fixed == "" { + return false + } + return code == fixed +} + // hashEmail returns a stable, hex-encoded HMAC-SHA256 prefix of email // suitable for use in structured logs. The key is per-process so the // same email maps to the same hash across log lines emitted by this diff --git a/backend/internal/auth/auth_e2e_test.go b/backend/internal/auth/auth_e2e_test.go index 1d433d5..f88460e 100644 --- a/backend/internal/auth/auth_e2e_test.go +++ b/backend/internal/auth/auth_e2e_test.go @@ -185,6 +185,35 @@ func authConfig() config.AuthConfig { } } +// buildServiceWithConfig wires every dependency around db using cfg as +// the auth configuration. Returns only the service — assertions on the +// dev-mode override path do not inspect the recording fakes. +func buildServiceWithConfig(t *testing.T, db *sql.DB, cfg config.AuthConfig) *auth.Service { + t.Helper() + store := auth.NewStore(db) + cache := auth.NewCache() + if err := cache.Warm(context.Background(), store); err != nil { + t.Fatalf("warm cache: %v", err) + } + userStore := user.NewStore(db) + userSvc := user.NewService(user.Deps{ + Store: userStore, + Cache: user.NewCache(), + UserNameMaxRetries: 10, + Now: time.Now, + }) + return auth.NewService(auth.Deps{ + Store: store, + Cache: cache, + User: userSvc, + Geo: newStubGeo(), + Mail: newRecordingMailer(), + Push: newRecordingPush(), + Config: cfg, + Now: time.Now, + }) +} + // buildService wires every dependency around db and returns the service // plus the recording fakes for assertions. func buildService(t *testing.T, db *sql.DB) (*auth.Service, *recordingMailer, *recordingPush, *stubGeo) { @@ -412,6 +441,55 @@ func TestSendEmailCodeThrottleReusesChallenge(t *testing.T) { } } +func TestConfirmEmailCodeDevFixedCodeBypass(t *testing.T) { + db := startPostgres(t) + cfg := authConfig() + cfg.DevFixedCode = "999999" + svc := buildServiceWithConfig(t, db, cfg) + ctx := context.Background() + + id, err := svc.SendEmailCode(ctx, "dev-bypass@example.test", "en", "", "") + if err != nil { + t.Fatalf("send: %v", err) + } + + session, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{ + ChallengeID: id, + Code: "999999", + ClientPublicKey: randomKey(t), + TimeZone: "UTC", + }) + if err != nil { + t.Fatalf("ConfirmEmailCode with dev fixed code: %v", err) + } + if session.DeviceSessionID == uuid.Nil { + t.Fatalf("dev fixed code did not produce a session") + } +} + +func TestConfirmEmailCodeDevFixedCodeStillRejectsWrong(t *testing.T) { + db := startPostgres(t) + cfg := authConfig() + cfg.DevFixedCode = "999999" + svc := buildServiceWithConfig(t, db, cfg) + ctx := context.Background() + + id, err := svc.SendEmailCode(ctx, "dev-bypass-wrong@example.test", "en", "", "") + if err != nil { + t.Fatalf("send: %v", err) + } + + _, err = svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{ + ChallengeID: id, + Code: "111111", + ClientPublicKey: randomKey(t), + TimeZone: "UTC", + }) + if !errors.Is(err, auth.ErrCodeMismatch) { + t.Fatalf("ConfirmEmailCode with neither real nor dev code = %v, want ErrCodeMismatch", err) + } +} + func TestConfirmEmailCodeWrongCode(t *testing.T) { db := startPostgres(t) svc, mailer, _, _ := buildService(t, db) diff --git a/backend/internal/auth/challenge.go b/backend/internal/auth/challenge.go index bfe7037..73cb364 100644 --- a/backend/internal/auth/challenge.go +++ b/backend/internal/auth/challenge.go @@ -171,15 +171,21 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi return Session{}, ErrTooManyAttempts } - if err := verifyCode(loaded.CodeHash, in.Code); err != nil { - if errors.Is(err, ErrCodeMismatch) { - s.deps.Logger.Info("auth challenge code mismatch", - zap.String("challenge_id", in.ChallengeID.String()), - zap.Int32("attempts", loaded.Attempts), - ) - return Session{}, ErrCodeMismatch + if !s.devFixedCodeMatches(in.Code) { + if err := verifyCode(loaded.CodeHash, in.Code); err != nil { + if errors.Is(err, ErrCodeMismatch) { + s.deps.Logger.Info("auth challenge code mismatch", + zap.String("challenge_id", in.ChallengeID.String()), + zap.Int32("attempts", loaded.Attempts), + ) + return Session{}, ErrCodeMismatch + } + return Session{}, err } - return Session{}, err + } else { + s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override", + zap.String("challenge_id", in.ChallengeID.String()), + ) } // Re-check permanent_block after verifying the code. SendEmailCode diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7432d5e..08170a6 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -71,6 +71,7 @@ const ( envAuthChallengeThrottleWindow = "BACKEND_AUTH_CHALLENGE_THROTTLE_WINDOW" envAuthChallengeThrottleMax = "BACKEND_AUTH_CHALLENGE_THROTTLE_MAX" envAuthUserNameMaxRetries = "BACKEND_AUTH_USERNAME_MAX_RETRIES" + envAuthDevFixedCode = "BACKEND_AUTH_DEV_FIXED_CODE" envLobbySweeperInterval = "BACKEND_LOBBY_SWEEPER_INTERVAL" envLobbyPendingRegistrationTTL = "BACKEND_LOBBY_PENDING_REGISTRATION_TTL" @@ -293,6 +294,16 @@ type AuthConfig struct { ChallengeMaxAttempts int ChallengeThrottle AuthChallengeThrottleConfig UserNameMaxRetries int + + // DevFixedCode, when non-empty, makes ConfirmEmailCode accept this + // literal as a valid code in addition to the bcrypt-verified one + // stored on the challenge row. The override is intended for the + // `tools/local-dev` stack so a developer can log in without + // reading codes out of Mailpit. The variable MUST stay unset in + // production: validation requires a six-digit decimal value, and + // the auth service emits a loud startup warning when it picks the + // override up. + DevFixedCode string } // AuthChallengeThrottleConfig bounds how many un-consumed, non-expired @@ -566,6 +577,7 @@ func LoadFromEnv() (Config, error) { if cfg.Auth.UserNameMaxRetries, err = loadInt(envAuthUserNameMaxRetries, cfg.Auth.UserNameMaxRetries); err != nil { return Config{}, err } + cfg.Auth.DevFixedCode = loadString(envAuthDevFixedCode, cfg.Auth.DevFixedCode) if cfg.Lobby.SweeperInterval, err = loadDuration(envLobbySweeperInterval, cfg.Lobby.SweeperInterval); err != nil { return Config{}, err @@ -745,6 +757,11 @@ func (c Config) Validate() error { if c.Auth.UserNameMaxRetries <= 0 { return fmt.Errorf("%s must be positive", envAuthUserNameMaxRetries) } + if c.Auth.DevFixedCode != "" { + if !isDecimalString(c.Auth.DevFixedCode, 6) { + return fmt.Errorf("%s must be a six-digit decimal string when set", envAuthDevFixedCode) + } + } if c.Lobby.SweeperInterval <= 0 { return fmt.Errorf("%s must be positive", envLobbySweeperInterval) @@ -809,6 +826,18 @@ func (c Config) Validate() error { return nil } +func isDecimalString(value string, length int) bool { + if len(value) != length { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + func loadString(name, fallback string) string { raw, ok := os.LookupEnv(name) if !ok { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index e07c2e7..5f2bd2d 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -77,6 +77,40 @@ func TestValidateRejectsUnknownTracesExporter(t *testing.T) { } } +func TestLoadFromEnvAcceptsDevFixedCode(t *testing.T) { + env := validEnv() + env["BACKEND_AUTH_DEV_FIXED_CODE"] = "123456" + setEnv(t, env) + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + if cfg.Auth.DevFixedCode != "123456" { + t.Fatalf("Auth.DevFixedCode = %q, want \"123456\"", cfg.Auth.DevFixedCode) + } +} + +func TestValidateRejectsDevFixedCodeWrongLength(t *testing.T) { + env := validEnv() + env["BACKEND_AUTH_DEV_FIXED_CODE"] = "12345" + setEnv(t, env) + + if _, err := LoadFromEnv(); err == nil || !strings.Contains(err.Error(), "BACKEND_AUTH_DEV_FIXED_CODE") { + t.Fatalf("expected DEV fixed-code length error, got %v", err) + } +} + +func TestValidateRejectsDevFixedCodeNonDecimal(t *testing.T) { + env := validEnv() + env["BACKEND_AUTH_DEV_FIXED_CODE"] = "abcdef" + setEnv(t, env) + + if _, err := LoadFromEnv(); err == nil || !strings.Contains(err.Error(), "BACKEND_AUTH_DEV_FIXED_CODE") { + t.Fatalf("expected DEV fixed-code decimal error, got %v", err) + } +} + func TestValidateRejectsPrometheusWithoutAddr(t *testing.T) { cfg := DefaultConfig() cfg.Postgres.DSN = "postgres://x:y@127.0.0.1/galaxy" diff --git a/tools/local-dev/.env b/tools/local-dev/.env new file mode 100644 index 0000000..e2dfecf --- /dev/null +++ b/tools/local-dev/.env @@ -0,0 +1,8 @@ +# Default environment for `make -C tools/local-dev up`. The compose +# file reads these via ${VAR:-} expansions; override per-developer by +# editing this file (it is committed only with the project defaults). + +# Six-digit decimal accepted by ConfirmEmailCode in addition to the +# real bcrypt-verified code. Leave the value blank to disable the +# override and force every login through Mailpit. +BACKEND_AUTH_DEV_FIXED_CODE=123456 diff --git a/tools/local-dev/Makefile b/tools/local-dev/Makefile new file mode 100644 index 0000000..761fe50 --- /dev/null +++ b/tools/local-dev/Makefile @@ -0,0 +1,53 @@ +.PHONY: help up down logs status rebuild clean psql logs-backend logs-gateway logs-mail wait + +.DEFAULT_GOAL := help + +COMPOSE := docker compose + +help: + @echo "Local development stack for the Galaxy UI:" + @echo " make up Build (if needed) and bring up the stack, wait until healthy" + @echo " make down Stop containers, keep volumes" + @echo " make rebuild Force rebuild of backend / gateway images and bring up" + @echo " make clean Stop and wipe volumes (postgres data, game state)" + @echo " make logs Tail all logs" + @echo " make logs-backend Tail only the backend logs" + @echo " make logs-gateway Tail only the gateway logs" + @echo " make logs-mail Tail only the mailpit logs" + @echo " make status docker compose ps" + @echo " make psql Open a psql shell as galaxy@galaxy_backend" + @echo "" + @echo "After 'make up', point the UI at the stack with:" + @echo " pnpm -C ui/frontend dev" + @echo "and open http://localhost:5173 (UI) plus http://localhost:8025 (Mailpit)." + +up: + $(COMPOSE) up -d --wait + +rebuild: + $(COMPOSE) build --no-cache backend gateway + $(COMPOSE) up -d --wait + +down: + $(COMPOSE) down + +clean: + $(COMPOSE) down -v + +logs: + $(COMPOSE) logs -f --tail=100 + +logs-backend: + $(COMPOSE) logs -f --tail=200 backend + +logs-gateway: + $(COMPOSE) logs -f --tail=200 gateway + +logs-mail: + $(COMPOSE) logs -f --tail=200 mailpit + +status: + $(COMPOSE) ps + +psql: + $(COMPOSE) exec postgres psql -U galaxy -d galaxy_backend diff --git a/tools/local-dev/README.md b/tools/local-dev/README.md new file mode 100644 index 0000000..682a6e0 --- /dev/null +++ b/tools/local-dev/README.md @@ -0,0 +1,161 @@ +# `tools/local-dev/` — Galaxy local development stack + +A docker-compose stack that brings up postgres + redis + mailpit + +backend + gateway so the UI Vite dev server (run on the host) can +talk to a real authenticated stack without any cloud dependency. + +The stack is the recommended baseline for UI work that goes beyond +the mocked Playwright fixtures: every payload exercises the real +FlatBuffers wire, every authenticated call verifies the response +signature against the dev keypair, and every email passes through +Mailpit's web UI for inspection. + +This stack is **not** a CI gate (that role belongs to +[`tools/local-ci/`](../local-ci/README.md), which boots a Gitea + +Actions runner and replays workflow files). The two stacks are +independent and can coexist on the same machine; they bind different +ports and use different networks. + +## Bring it up + +```sh +make -C tools/local-dev up +``` + +`up` builds the local-dev backend and gateway images on first run +(pulls postgres, redis, mailpit), waits for every service to report +healthy, and returns. Subsequent invocations reuse the built images. + +After the stack is healthy: + +```sh +pnpm -C ui/frontend dev +``` + +Open for the UI and + for Mailpit. + +## Daily flow + +```sh +make -C tools/local-dev up # bring up (idempotent, fast on warm cache) +pnpm -C ui/frontend dev # in another terminal +# ...edit UI, browse, repeat... +make -C tools/local-dev down # stop containers, keep state +``` + +State persists in named Docker volumes between `up`/`down` cycles, so +games created on Tuesday survive into Wednesday. Wipe with +`make clean` when you want a fresh database. + +## Logging in + +Two paths coexist by default: + +1. **Fixed dev code (fast).** `tools/local-dev/.env` ships + `BACKEND_AUTH_DEV_FIXED_CODE=123456`. After requesting a code in + the UI, type `123456` — `ConfirmEmailCode` accepts that literal + in addition to the real bcrypt-verified code stored on the + challenge row. The override emits a loud warning at backend boot + and is rejected by the production env loader (`BACKEND_ENV` guard + in `backend/internal/config`). +2. **Real Mailpit code.** Open , find the + most recent message, copy the six-digit code, paste it into the + UI. This exercises the full mail outbox path, including SMTP + handoff and gomail TLS-mode handling. + +To force the second path (no fast-bypass), edit +`tools/local-dev/.env` and clear `BACKEND_AUTH_DEV_FIXED_CODE`, then +`make rebuild` (or simply `docker compose up -d backend` to recreate +the backend with the new env). + +## Network map + +``` +host compose network "galaxy-local-dev-net" + ┌────────────────────────────┐ ┌──────────────────────────────┐ + │ pnpm dev localhost:5173 │──HMR──▶│ host (Vite) │ + │ browser localhost:8080 │──REST/Connect─▶│ gateway:8080 │ + │ browser localhost:8025 │─────▶│ mailpit:8025 (web UI) │ + │ psql localhost:5433 │─────▶│ postgres:5432 │ + │ redis-cli localhost:6380 │─────▶│ redis:6379 │ + └────────────────────────────┘ │ ↳ backend:8080 (HTTP) │ + │ ↳ backend:8081 (gRPC push) │ + │ ↳ mailpit:1025 (SMTP in) │ + └──────────────────────────────┘ +``` + +Only the gateway public port (8080) and the mailpit web UI (8025) +are needed for normal UI work. Postgres (5433) and Redis (6380) are +exposed for direct inspection (`make psql`, `redis-cli -h localhost +-p 6380 -a galaxy-dev`). + +## Make targets + +```text +make up Bring up the stack (build if needed) and wait for health +make rebuild Rebuild the backend / gateway images (ignores cache) +make down Stop containers, keep volumes +make clean Stop and wipe volumes (postgres + game-state) +make logs Tail every service's logs +make logs-backend Tail backend only +make logs-gateway Tail gateway only +make logs-mail Tail mailpit only +make psql Open a psql shell as galaxy@galaxy_backend +make status docker compose ps +``` + +## Files + +- `docker-compose.yml` — five services: postgres, redis, mailpit, + backend, gateway, plus shared network and volumes. +- `backend.Dockerfile`, `gateway.Dockerfile` — local-dev runtime + images built on alpine (so `wget` is available for the compose + healthchecks). The build stage mirrors `backend/Dockerfile` and + `gateway/Dockerfile` exactly. +- `Makefile` — wrapper over `docker compose` that keeps the muscle + memory close to `tools/local-ci/`'s Makefile. +- `.env` — committed defaults for the compose `${VAR:-}` + expansions. Edit per-developer or override via your shell. +- `keys/gateway-response.pem`, `keys/gateway-response.pub` — dev-only + Ed25519 keypair used by the gateway for response signing. Pairs + with the `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` value in + `ui/frontend/.env.development`. See `keys/README.md` before + rotating. +- `keys/regenerate.go` — one-shot Go helper that regenerates the + pair and prints the new base64 public key. + +## Troubleshooting + +- **`make up` reports a build error mentioning `pkg/cronutil`** — + upstream module list drifted; copy any new `pkg//` line into + the local-dev `backend.Dockerfile` / `gateway.Dockerfile` to match + `backend/Dockerfile` / `gateway/Dockerfile`. +- **Gateway exits at boot with "redis: …"** — the redis container is + still bootstrapping. `make up --wait` waits for healthchecks; if + it times out, increase `start_period` in the gateway service or + inspect `make logs-redis`. +- **Login form rejects every code** — confirm + `BACKEND_AUTH_DEV_FIXED_CODE` is set in `tools/local-dev/.env` and + the backend has been recreated since the last edit + (`docker compose up -d backend`). Real Mailpit codes work + regardless. +- **UI talks to old gateway**: Vite caches `import.meta.env` at boot. + Restart `pnpm dev` after editing + `ui/frontend/.env.development.local`. +- **Port 8080 already in use** — stop the conflicting service or + edit the host-side mapping in `docker-compose.yml` (gateway's + `ports:` entry) plus the matching `VITE_GATEWAY_BASE_URL` in + `ui/frontend/.env.development.local`. + +## Relationship to other infrastructure + +- `tools/local-ci/` — Gitea + Actions runner, replays + `.gitea/workflows/*` against a pushed branch. Different stack, + different purpose; coexists with local-dev on the same machine. +- `integration/testenv/` — testcontainers harness used by + `make -C integration integration`. Uses the same images + (`backend/Dockerfile`, `gateway/Dockerfile`) at production + defaults; do not confuse with this local-dev stack, which carries + alpine-runtime images for ergonomics and the dev-mode auth + override. diff --git a/tools/local-dev/backend.Dockerfile b/tools/local-dev/backend.Dockerfile new file mode 100644 index 0000000..bceab72 --- /dev/null +++ b/tools/local-dev/backend.Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1.7 +# +# Local-dev image for the backend service. Mirrors the structure of +# `backend/Dockerfile` (the integration/production image) but switches +# the runtime stage to alpine so docker-compose healthchecks can shell +# out to `wget` and the container can run as root for Docker-socket +# access without needing the production-grade nonroot guarantees. +# +# Build via the local-dev compose: `make -C tools/local-dev up`. The +# build context is the repository root. + +FROM golang:1.26.2-alpine AS builder +WORKDIR /src +ENV CGO_ENABLED=0 GOFLAGS=-trimpath + +COPY pkg/cronutil/ ./pkg/cronutil/ +COPY pkg/error/ ./pkg/error/ +COPY pkg/geoip/ ./pkg/geoip/ +COPY pkg/model/ ./pkg/model/ +COPY pkg/postgres/ ./pkg/postgres/ +COPY pkg/schema/ ./pkg/schema/ +COPY pkg/transcoder/ ./pkg/transcoder/ +COPY pkg/util/ ./pkg/util/ +COPY backend/ ./backend/ + +RUN <<'EOF' cat > go.work +go 1.26.2 + +use ( + ./backend + ./pkg/cronutil + ./pkg/error + ./pkg/geoip + ./pkg/model + ./pkg/postgres + ./pkg/schema + ./pkg/transcoder + ./pkg/util +) + +replace ( + galaxy/cronutil v0.0.0 => ./pkg/cronutil + galaxy/error v0.0.0 => ./pkg/error + galaxy/geoip v0.0.0 => ./pkg/geoip + galaxy/model v0.0.0 => ./pkg/model + galaxy/postgres v0.0.0 => ./pkg/postgres + galaxy/schema v0.0.0 => ./pkg/schema + galaxy/transcoder v0.0.0 => ./pkg/transcoder + galaxy/util v0.0.0 => ./pkg/util +) +EOF + +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go build -o /out/backend ./backend/cmd/backend + +FROM alpine:3.20 AS runtime + +LABEL org.opencontainers.image.title="galaxy-backend-local-dev" + +RUN apk add --no-cache wget ca-certificates + +EXPOSE 8080 +EXPOSE 8081 + +COPY --from=builder /out/backend /usr/local/bin/backend + +ENTRYPOINT ["/usr/local/bin/backend"] diff --git a/tools/local-dev/docker-compose.yml b/tools/local-dev/docker-compose.yml new file mode 100644 index 0000000..28e6b35 --- /dev/null +++ b/tools/local-dev/docker-compose.yml @@ -0,0 +1,186 @@ +# Local development stack for the Galaxy UI. +# +# Brings up postgres + redis + mailpit + backend + gateway so the UI +# Vite dev server (run on the host with `pnpm -C ui/frontend dev`) can +# talk to a real authenticated stack without any cloud dependency. +# +# Browser: http://localhost:8080 (gateway public REST + Connect-Web) +# Mailpit UI: http://localhost:8025 +# Postgres: localhost:5433 (host-mapped) +# Redis: localhost:6380 (host-mapped) +# +# Bring up: make -C tools/local-dev up +# Tear down: make -C tools/local-dev down +# Wipe state: make -C tools/local-dev clean +# +# The backend reads `BACKEND_AUTH_DEV_FIXED_CODE=123456` from the +# `.env` file alongside this compose; ConfirmEmailCode accepts that +# literal in addition to the real bcrypt-verified code, so a developer +# can log in without touching Mailpit. Real codes still arrive in +# Mailpit; both paths coexist. + +services: + postgres: + image: postgres:16-alpine + container_name: galaxy-local-dev-postgres + restart: unless-stopped + environment: + POSTGRES_USER: galaxy + POSTGRES_PASSWORD: galaxy + POSTGRES_DB: galaxy_backend + ports: + - "5433:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - galaxy-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U galaxy -d galaxy_backend"] + interval: 3s + timeout: 3s + retries: 30 + start_period: 5s + + redis: + image: redis:7-alpine + container_name: galaxy-local-dev-redis + restart: unless-stopped + command: + - redis-server + - --requirepass + - galaxy-dev + - --appendonly + - "no" + - --save + - "" + ports: + - "6380:6379" + networks: + - galaxy-net + healthcheck: + test: ["CMD", "redis-cli", "-a", "galaxy-dev", "PING"] + interval: 3s + timeout: 3s + retries: 30 + start_period: 3s + + mailpit: + image: axllent/mailpit:v1.21 + container_name: galaxy-local-dev-mailpit + restart: unless-stopped + ports: + - "8025:8025" + networks: + - galaxy-net + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:8025/livez"] + interval: 3s + timeout: 3s + retries: 30 + start_period: 3s + + backend: + build: + context: ../.. + dockerfile: tools/local-dev/backend.Dockerfile + image: galaxy/backend:local-dev + container_name: galaxy-local-dev-backend + restart: unless-stopped + user: "0:0" + depends_on: + postgres: + condition: service_healthy + mailpit: + condition: service_healthy + environment: + BACKEND_LOGGING_LEVEL: debug + BACKEND_HTTP_LISTEN_ADDR: ":8080" + BACKEND_GRPC_PUSH_LISTEN_ADDR: ":8081" + BACKEND_POSTGRES_DSN: "postgres://galaxy:galaxy@postgres:5432/galaxy_backend?search_path=backend&sslmode=disable" + BACKEND_SMTP_HOST: mailpit + BACKEND_SMTP_PORT: "1025" + BACKEND_SMTP_FROM: "galaxy-backend@galaxy.local" + BACKEND_SMTP_TLS_MODE: none + BACKEND_DOCKER_NETWORK: galaxy-local-dev-net + BACKEND_GAME_STATE_ROOT: /var/lib/galaxy/game-state + BACKEND_GEOIP_DB_PATH: /var/lib/galaxy/geoip.mmdb + BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.local + BACKEND_AUTH_CHALLENGE_THROTTLE_MAX: "100" + BACKEND_MAIL_WORKER_INTERVAL: 500ms + BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms + BACKEND_OTEL_TRACES_EXPORTER: none + BACKEND_OTEL_METRICS_EXPORTER: none + BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - game-state:/var/lib/galaxy/game-state + - ../../pkg/geoip/test-data/test-data/GeoIP2-Country-Test.mmdb:/var/lib/galaxy/geoip.mmdb:ro + networks: + - galaxy-net + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"] + interval: 3s + timeout: 3s + retries: 60 + start_period: 10s + + gateway: + build: + context: ../.. + dockerfile: tools/local-dev/gateway.Dockerfile + image: galaxy/gateway:local-dev + container_name: galaxy-local-dev-gateway + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + redis: + condition: service_healthy + environment: + GATEWAY_LOG_LEVEL: debug + GATEWAY_PUBLIC_HTTP_ADDR: ":8080" + GATEWAY_AUTHENTICATED_GRPC_ADDR: ":9090" + GATEWAY_BACKEND_HTTP_URL: "http://backend:8080" + GATEWAY_BACKEND_GRPC_PUSH_URL: "backend:8081" + GATEWAY_BACKEND_GATEWAY_CLIENT_ID: local-dev-gateway-1 + GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH: /run/secrets/gateway-response.pem + GATEWAY_REDIS_MASTER_ADDR: "redis:6379" + GATEWAY_REDIS_PASSWORD: galaxy-dev + # Loosen anti-abuse so a developer hammering the form does not + # rate-limit themselves between cycles. + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST: "1000" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST: "1000" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST: "1000" + GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000" + GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_BURST: "1000" + GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_BURST: "1000" + GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST: "1000" + ports: + - "8080:8080" + volumes: + - ./keys/gateway-response.pem:/run/secrets/gateway-response.pem:ro + networks: + - galaxy-net + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:8080/healthz"] + interval: 3s + timeout: 3s + retries: 30 + start_period: 5s + +networks: + galaxy-net: + name: galaxy-local-dev-net + +volumes: + postgres-data: + name: galaxy-local-dev-postgres-data + game-state: + name: galaxy-local-dev-game-state diff --git a/tools/local-dev/gateway.Dockerfile b/tools/local-dev/gateway.Dockerfile new file mode 100644 index 0000000..4bc5d98 --- /dev/null +++ b/tools/local-dev/gateway.Dockerfile @@ -0,0 +1,74 @@ +# syntax=docker/dockerfile:1.7 +# +# Local-dev image for the gateway service. Mirrors `gateway/Dockerfile` +# (the integration/production image) but switches the runtime stage to +# alpine so docker-compose healthchecks can shell out to `wget`. +# +# Build via the local-dev compose: `make -C tools/local-dev up`. The +# build context is the repository root. + +FROM golang:1.26.2-alpine AS builder +WORKDIR /src +ENV CGO_ENABLED=0 GOFLAGS=-trimpath + +COPY pkg/cronutil/ ./pkg/cronutil/ +COPY pkg/error/ ./pkg/error/ +COPY pkg/geoip/ ./pkg/geoip/ +COPY pkg/model/ ./pkg/model/ +COPY pkg/postgres/ ./pkg/postgres/ +COPY pkg/redisconn/ ./pkg/redisconn/ +COPY pkg/schema/ ./pkg/schema/ +COPY pkg/transcoder/ ./pkg/transcoder/ +COPY pkg/util/ ./pkg/util/ +COPY ui/core/ ./ui/core/ +COPY backend/ ./backend/ +COPY gateway/ ./gateway/ + +RUN <<'EOF' cat > go.work +go 1.26.2 + +use ( + ./backend + ./gateway + ./pkg/cronutil + ./pkg/error + ./pkg/geoip + ./pkg/model + ./pkg/postgres + ./pkg/redisconn + ./pkg/schema + ./pkg/transcoder + ./pkg/util + ./ui/core +) + +replace ( + galaxy/cronutil v0.0.0 => ./pkg/cronutil + galaxy/error v0.0.0 => ./pkg/error + galaxy/geoip v0.0.0 => ./pkg/geoip + galaxy/model v0.0.0 => ./pkg/model + galaxy/postgres v0.0.0 => ./pkg/postgres + galaxy/redisconn v0.0.0 => ./pkg/redisconn + galaxy/schema v0.0.0 => ./pkg/schema + galaxy/transcoder v0.0.0 => ./pkg/transcoder + galaxy/util v0.0.0 => ./pkg/util + galaxy/core v0.0.0 => ./ui/core +) +EOF + +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go build -o /out/gateway ./gateway/cmd/gateway + +FROM alpine:3.20 AS runtime + +LABEL org.opencontainers.image.title="galaxy-gateway-local-dev" + +RUN apk add --no-cache wget ca-certificates + +EXPOSE 8080 +EXPOSE 9090 + +COPY --from=builder /out/gateway /usr/local/bin/gateway + +ENTRYPOINT ["/usr/local/bin/gateway"] diff --git a/tools/local-dev/keys/README.md b/tools/local-dev/keys/README.md new file mode 100644 index 0000000..45e0e5c --- /dev/null +++ b/tools/local-dev/keys/README.md @@ -0,0 +1,34 @@ +# `tools/local-dev/keys/` + +DEV-ONLY cryptographic material used by the `tools/local-dev/` stack. + +**Never use any key in this directory in a non-local environment.** + +## Files + +- `gateway-response.pem` — gateway response-signing private key, PKCS#8 + PEM, Ed25519. Mounted into the gateway container at + `/run/secrets/gateway-response.pem` and pointed to via + `GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH`. +- `gateway-response.pub` — matching raw 32-byte public key, standard + base64. Copied verbatim into `ui/frontend/.env.development` as + `VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. + +## Regenerating + +The keypair is committed because it must be deterministic across +developer checkouts (the UI's `.env.development` ships the exact +base64 of the public half). Rotate only when a leak is suspected; the +keys never reach a non-local environment in normal operation. + +To regenerate from a Go one-shot: + +```sh +cd tools/local-dev/keys +go run ./regenerate.go +``` + +The helper writes a fresh PEM, prints the matching public-key base64, +and updates `gateway-response.pub`. After regeneration, copy the new +`VITE_GATEWAY_RESPONSE_PUBLIC_KEY` value from `gateway-response.pub` +into `ui/frontend/.env.development` and commit both changes together. diff --git a/tools/local-dev/keys/gateway-response.pem b/tools/local-dev/keys/gateway-response.pem new file mode 100644 index 0000000..eebaf53 --- /dev/null +++ b/tools/local-dev/keys/gateway-response.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIHqW94EpSePdiujbP1Wh1GIz+vuDnFU8HDeFfaNwcovi +-----END PRIVATE KEY----- diff --git a/tools/local-dev/keys/gateway-response.pub b/tools/local-dev/keys/gateway-response.pub new file mode 100644 index 0000000..b7d69c2 --- /dev/null +++ b/tools/local-dev/keys/gateway-response.pub @@ -0,0 +1,4 @@ +# DEV-ONLY gateway response-signing public key (raw 32-byte Ed25519, +# standard non-URL-safe base64). Pairs with `gateway-response.pem`. +# Never use in any non-local environment. +nIG54tCuNiIKrazt8Hh7YxmmU/BhpseGhIIgj164Chw= diff --git a/tools/local-dev/keys/regenerate.go b/tools/local-dev/keys/regenerate.go new file mode 100644 index 0000000..62a94b3 --- /dev/null +++ b/tools/local-dev/keys/regenerate.go @@ -0,0 +1,47 @@ +// Regenerate `gateway-response.pem` and `gateway-response.pub`. +// +// Run from this directory: `go run ./regenerate.go`. The keys are +// committed and used only by the `tools/local-dev/` stack; rotate by +// re-running and committing both files together with the matching +// `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` update in +// `ui/frontend/.env.development`. + +//go:build ignore + +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "os" +) + +func main() { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + fmt.Fprintln(os.Stderr, "generate:", err) + os.Exit(1) + } + pkcs8, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + fmt.Fprintln(os.Stderr, "marshal:", err) + os.Exit(1) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}) + if err := os.WriteFile("gateway-response.pem", pemBytes, 0o600); err != nil { + fmt.Fprintln(os.Stderr, "write pem:", err) + os.Exit(1) + } + + pubB64 := base64.StdEncoding.EncodeToString(pub) + pubBlock := fmt.Sprintf("# DEV-ONLY gateway response-signing public key (raw 32-byte Ed25519,\n# standard non-URL-safe base64). Pairs with `gateway-response.pem`.\n# Never use in any non-local environment.\n%s\n", pubB64) + if err := os.WriteFile("gateway-response.pub", []byte(pubBlock), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "write pub:", err) + os.Exit(1) + } + fmt.Printf("VITE_GATEWAY_RESPONSE_PUBLIC_KEY=%s\n", pubB64) +} diff --git a/ui/README.md b/ui/README.md index 69c2226..80c51ca 100644 --- a/ui/README.md +++ b/ui/README.md @@ -132,6 +132,24 @@ make android Capacitor + gradle Phase 32+ make all every target above ``` +## Local development + +For UI work against a real stack, the `tools/local-dev/` docker +compose brings up postgres + redis + mailpit + backend + gateway in +one command, and `ui/frontend/.env.development` is already wired to +talk to it: + +```sh +make -C tools/local-dev up # build + start, wait for healthy +pnpm -C ui/frontend dev # Vite on the host +# UI: http://localhost:5173 +# Mailpit: http://localhost:8025 +``` + +The stack accepts a fixed dev code (`123456`) in addition to the +real Mailpit-delivered one. Full runbook in +[`../tools/local-dev/README.md`](../tools/local-dev/README.md). + ## Per-phase docs Topic docs live under `ui/docs/` and are added per phase as they're diff --git a/ui/docs/testing.md b/ui/docs/testing.md index ab34d4c..6305f8e 100644 --- a/ui/docs/testing.md +++ b/ui/docs/testing.md @@ -83,6 +83,32 @@ go test -count=1 \ ./pkg/storage/... ./pkg/transcoder/... ./pkg/util/... ``` +## Local development stack + +For UI work that needs a real authenticated stack (verifying the +FlatBuffers wire end-to-end, exercising a real lobby flow, hitting +real Mailpit), bring up `tools/local-dev/`: + +```sh +make -C tools/local-dev up # postgres + redis + mailpit + backend + gateway +pnpm -C ui/frontend dev # Vite on the host, talks to the stack +``` + +`ui/frontend/.env.development` already targets the stack +(`http://localhost:8080`) and pins the matching response-signing +public key from `tools/local-dev/keys/`. Per-developer overrides go +into `.env.development.local` (gitignored). + +The stack honours `BACKEND_AUTH_DEV_FIXED_CODE` (default `123456` in +`tools/local-dev/.env`) so the login form takes that literal in +addition to the real Mailpit code; see +[`../../tools/local-dev/README.md`](../../tools/local-dev/README.md) +for the full runbook (regenerating the dev keypair, switching the +mode off, troubleshooting common boot issues). + +The local-dev stack is independent from the local-ci stack below; +they bind different ports and can run side by side. + ## Local CI verification `tools/local-ci/` ships a self-contained Gitea + Actions runner via diff --git a/ui/frontend/.env.development b/ui/frontend/.env.development new file mode 100644 index 0000000..6536944 --- /dev/null +++ b/ui/frontend/.env.development @@ -0,0 +1,12 @@ +# Vite picks this file up automatically when running in `development` +# mode (`pnpm dev`, `pnpm test:e2e`). It targets the local-dev stack +# brought up by `make -C tools/local-dev up`. Per-developer overrides +# live in `.env.development.local` (gitignored by Vite convention). + +# Gateway public REST + Connect-Web edge listener. +VITE_GATEWAY_BASE_URL=http://localhost:8080 + +# Standard non-URL-safe base64 of the gateway response-signing public +# key. Pairs with `tools/local-dev/keys/gateway-response.pem`. The pair +# is dev-only — see `tools/local-dev/keys/README.md` before rotating. +VITE_GATEWAY_RESPONSE_PUBLIC_KEY=nIG54tCuNiIKrazt8Hh7YxmmU/BhpseGhIIgj164Chw= diff --git a/ui/frontend/.env.example b/ui/frontend/.env.example index 83131cb..b53378d 100644 --- a/ui/frontend/.env.example +++ b/ui/frontend/.env.example @@ -1,18 +1,17 @@ # Vite reads any variable prefixed with `VITE_` and exposes it on -# `import.meta.env`. Copy this file to `.env.local` (gitignored) and -# fill in the values before running `pnpm run dev` or `pnpm exec -# playwright test` against a real gateway. +# `import.meta.env`. The committed `.env.development` already targets +# the `tools/local-dev/` docker stack — most contributors don't need +# to copy this file. Use a `.env.development.local` (gitignored by +# Vite convention) for per-developer overrides, or use `.env.local` +# when running `pnpm exec playwright test` against a non-default +# gateway. # Base URL of the gateway public REST surface and Connect-Web edge -# listener. Both surfaces share the same host and port. Defaults to -# the local dev address used by `tools/local-ci` and the Go-side -# integration suite. +# listener. Both surfaces share the same host and port. VITE_GATEWAY_BASE_URL=http://localhost:8080 # Standard (non-URL-safe) base64 of the gateway's raw 32-byte -# Ed25519 response-signing public key. Required only for -# authenticated unary calls; unauthenticated routes (`/login`) -# work without it. For local dev, take the value the gateway -# integration container exports as `ResponseSignerPublic` (see -# `integration/testenv/gateway.go`). +# Ed25519 response-signing public key. The local-dev stack ships a +# checked-in keypair under `tools/local-dev/keys/`; for any other +# environment, take the value from the gateway operator. VITE_GATEWAY_RESPONSE_PUBLIC_KEY=