ui: plan 01-27 done #1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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=
|
||||||
|
|||||||
Reference in New Issue
Block a user