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 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 09:42:29 +02:00
parent f57a290432
commit 69fa6b30e1
20 changed files with 887 additions and 19 deletions
+7
View File
@@ -1,3 +1,10 @@
.codex .codex
.vscode/ .vscode/
artifacts/.claude/scheduled_tasks.lock 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
+21
View File
@@ -76,9 +76,30 @@ func NewService(deps Deps) *Service {
// not a security primitive, so a constant key is acceptable. // not a security primitive, so a constant key is acceptable.
copy(key, []byte("galaxy-backend-auth-fallback-key")) 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} 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 // hashEmail returns a stable, hex-encoded HMAC-SHA256 prefix of email
// suitable for use in structured logs. The key is per-process so the // 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 // same email maps to the same hash across log lines emitted by this
+78
View File
@@ -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 // buildService wires every dependency around db and returns the service
// plus the recording fakes for assertions. // plus the recording fakes for assertions.
func buildService(t *testing.T, db *sql.DB) (*auth.Service, *recordingMailer, *recordingPush, *stubGeo) { 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) { func TestConfirmEmailCodeWrongCode(t *testing.T) {
db := startPostgres(t) db := startPostgres(t)
svc, mailer, _, _ := buildService(t, db) svc, mailer, _, _ := buildService(t, db)
+6
View File
@@ -171,6 +171,7 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
return Session{}, ErrTooManyAttempts return Session{}, ErrTooManyAttempts
} }
if !s.devFixedCodeMatches(in.Code) {
if err := verifyCode(loaded.CodeHash, in.Code); err != nil { if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
if errors.Is(err, ErrCodeMismatch) { if errors.Is(err, ErrCodeMismatch) {
s.deps.Logger.Info("auth challenge code mismatch", s.deps.Logger.Info("auth challenge code mismatch",
@@ -181,6 +182,11 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
} }
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 // Re-check permanent_block after verifying the code. SendEmailCode
// guards against fresh challenges for already-blocked addresses; // guards against fresh challenges for already-blocked addresses;
+29
View File
@@ -71,6 +71,7 @@ const (
envAuthChallengeThrottleWindow = "BACKEND_AUTH_CHALLENGE_THROTTLE_WINDOW" envAuthChallengeThrottleWindow = "BACKEND_AUTH_CHALLENGE_THROTTLE_WINDOW"
envAuthChallengeThrottleMax = "BACKEND_AUTH_CHALLENGE_THROTTLE_MAX" envAuthChallengeThrottleMax = "BACKEND_AUTH_CHALLENGE_THROTTLE_MAX"
envAuthUserNameMaxRetries = "BACKEND_AUTH_USERNAME_MAX_RETRIES" envAuthUserNameMaxRetries = "BACKEND_AUTH_USERNAME_MAX_RETRIES"
envAuthDevFixedCode = "BACKEND_AUTH_DEV_FIXED_CODE"
envLobbySweeperInterval = "BACKEND_LOBBY_SWEEPER_INTERVAL" envLobbySweeperInterval = "BACKEND_LOBBY_SWEEPER_INTERVAL"
envLobbyPendingRegistrationTTL = "BACKEND_LOBBY_PENDING_REGISTRATION_TTL" envLobbyPendingRegistrationTTL = "BACKEND_LOBBY_PENDING_REGISTRATION_TTL"
@@ -293,6 +294,16 @@ type AuthConfig struct {
ChallengeMaxAttempts int ChallengeMaxAttempts int
ChallengeThrottle AuthChallengeThrottleConfig ChallengeThrottle AuthChallengeThrottleConfig
UserNameMaxRetries int 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 // 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 { if cfg.Auth.UserNameMaxRetries, err = loadInt(envAuthUserNameMaxRetries, cfg.Auth.UserNameMaxRetries); err != nil {
return Config{}, err return Config{}, err
} }
cfg.Auth.DevFixedCode = loadString(envAuthDevFixedCode, cfg.Auth.DevFixedCode)
if cfg.Lobby.SweeperInterval, err = loadDuration(envLobbySweeperInterval, cfg.Lobby.SweeperInterval); err != nil { if cfg.Lobby.SweeperInterval, err = loadDuration(envLobbySweeperInterval, cfg.Lobby.SweeperInterval); err != nil {
return Config{}, err return Config{}, err
@@ -745,6 +757,11 @@ func (c Config) Validate() error {
if c.Auth.UserNameMaxRetries <= 0 { if c.Auth.UserNameMaxRetries <= 0 {
return fmt.Errorf("%s must be positive", envAuthUserNameMaxRetries) 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 { if c.Lobby.SweeperInterval <= 0 {
return fmt.Errorf("%s must be positive", envLobbySweeperInterval) return fmt.Errorf("%s must be positive", envLobbySweeperInterval)
@@ -809,6 +826,18 @@ func (c Config) Validate() error {
return nil 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 { func loadString(name, fallback string) string {
raw, ok := os.LookupEnv(name) raw, ok := os.LookupEnv(name)
if !ok { if !ok {
+34
View File
@@ -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) { func TestValidateRejectsPrometheusWithoutAddr(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.Postgres.DSN = "postgres://x:y@127.0.0.1/galaxy" cfg.Postgres.DSN = "postgres://x:y@127.0.0.1/galaxy"
+8
View File
@@ -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
+53
View File
@@ -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
+161
View File
@@ -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 <http://localhost:5173> for the UI and
<http://localhost:8025> 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 <http://localhost:8025>, 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/<name>/` 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.
+68
View File
@@ -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"]
+186
View File
@@ -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
+74
View File
@@ -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"]
+34
View File
@@ -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.
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIHqW94EpSePdiujbP1Wh1GIz+vuDnFU8HDeFfaNwcovi
-----END PRIVATE KEY-----
@@ -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=
+47
View File
@@ -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)
}
+18
View File
@@ -132,6 +132,24 @@ make android Capacitor + gradle Phase 32+
make all every target above 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 ## Per-phase docs
Topic docs live under `ui/docs/` and are added per phase as they're Topic docs live under `ui/docs/` and are added per phase as they're
+26
View File
@@ -83,6 +83,32 @@ go test -count=1 \
./pkg/storage/... ./pkg/transcoder/... ./pkg/util/... ./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 ## Local CI verification
`tools/local-ci/` ships a self-contained Gitea + Actions runner via `tools/local-ci/` ships a self-contained Gitea + Actions runner via
+12
View File
@@ -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=
+10 -11
View File
@@ -1,18 +1,17 @@
# Vite reads any variable prefixed with `VITE_` and exposes it on # Vite reads any variable prefixed with `VITE_` and exposes it on
# `import.meta.env`. Copy this file to `.env.local` (gitignored) and # `import.meta.env`. The committed `.env.development` already targets
# fill in the values before running `pnpm run dev` or `pnpm exec # the `tools/local-dev/` docker stack — most contributors don't need
# playwright test` against a real gateway. # 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 # Base URL of the gateway public REST surface and Connect-Web edge
# listener. Both surfaces share the same host and port. Defaults to # listener. Both surfaces share the same host and port.
# the local dev address used by `tools/local-ci` and the Go-side
# integration suite.
VITE_GATEWAY_BASE_URL=http://localhost:8080 VITE_GATEWAY_BASE_URL=http://localhost:8080
# Standard (non-URL-safe) base64 of the gateway's raw 32-byte # Standard (non-URL-safe) base64 of the gateway's raw 32-byte
# Ed25519 response-signing public key. Required only for # Ed25519 response-signing public key. The local-dev stack ships a
# authenticated unary calls; unauthenticated routes (`/login`) # checked-in keypair under `tools/local-dev/keys/`; for any other
# work without it. For local dev, take the value the gateway # environment, take the value from the gateway operator.
# integration container exports as `ResponseSignerPublic` (see
# `integration/testenv/gateway.go`).
VITE_GATEWAY_RESPONSE_PUBLIC_KEY= VITE_GATEWAY_RESPONSE_PUBLIC_KEY=