diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 0d003c5..5d20fc7 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -1,7 +1,7 @@ // Command web is the Phase 1 walking-skeleton HTTP server. It loads env, -// builds a slog handler, opens a pgxpool, mounts the chi router, and serves -// /, /healthz, /demo/time, and /static/* with graceful shutdown on -// SIGINT/SIGTERM (CONTEXT D-19). +// builds a slog handler, opens a pgxpool, runs goose migrations, mounts the +// chi router, and serves /, /healthz, /readyz, /demo/time, and /static/* with +// graceful shutdown on SIGINT/SIGTERM (CONTEXT D-19, D-10, DEPLOY-03/04). // // No .env parser lives here — `.env` is exported into the process // environment by `just dev`; production injects real env vars (D-15). @@ -18,6 +18,7 @@ import ( "syscall" "time" + assets "backend" "backend/internal/auth" "backend/internal/db" "backend/internal/db/sqlc" @@ -68,6 +69,13 @@ func main() { os.Exit(1) } + // D-10: run goose migrations from the embedded FS before constructing the + // router. goose.Up is idempotent — already-applied migrations are skipped. + if err := db.RunMigrations(ctx, pool, assets.Migrations); err != nil { + slog.Error("migrations failed", "err", err) + os.Exit(1) + } + q := sqlc.New(pool) store := auth.NewStore(q) secure := env != "development" && env != "dev" @@ -113,7 +121,8 @@ func main() { fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} - router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) + // D-09: pass the embedded static FS — binary has zero runtime file dependencies. + router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) srv := &http.Server{ Addr: ":" + port, diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index 184974f..01604de 100644 --- a/backend/internal/web/csrf_test.go +++ b/backend/internal/web/csrf_test.go @@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler { csrfKey[i] = byte(i + 1) } deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") } // extractCSRFToken performs a GET request and extracts the _csrf token from the diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 87e2e50..93656b5 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" "time" @@ -33,14 +34,14 @@ var testCSRFKey = func() []byte { // Referer header are accepted. func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // newTestRouterWithLimiter builds a router with an injected LimiterStore, // enabling rate-limit tests to use a fake clock. func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // getCSRFToken performs a GET request to path and extracts the CSRF token diff --git a/backend/internal/web/handlers_files_test.go b/backend/internal/web/handlers_files_test.go index daa5980..18e43d7 100644 --- a/backend/internal/web/handlers_files_test.go +++ b/backend/internal/web/handlers_files_test.go @@ -18,6 +18,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -59,7 +60,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") } // ---- TestFileUpload (FILE-01, FILE-02) ---- @@ -164,7 +165,7 @@ func TestFileUploadTooLarge(t *testing.T) { tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1} - router := NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") + router := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") user := preInsertUser(t, ctx, q, "filelarge@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index 0e86425..db16eca 100644 --- a/backend/internal/web/handlers_tablos_test.go +++ b/backend/internal/web/handlers_tablos_test.go @@ -13,6 +13,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" @@ -26,7 +27,7 @@ import ( func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // loginUser signs up a user and returns the session cookie set after signup. diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index 989f49f..09733d7 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -15,6 +15,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" @@ -29,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // ---- TestTasksKanbanRenders (TASK-01) ---- diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 4baa004..76e3f40 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -2,6 +2,7 @@ package web import ( "context" + "io/fs" "log/slog" "net/http" "time" @@ -12,7 +13,7 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" ) -// Pinger is the contract /healthz uses to probe the data plane. *pgxpool.Pool +// Pinger is the contract /readyz uses to probe the data plane. *pgxpool.Pool // satisfies this interface out of the box, which is why cmd/web passes the // pool directly to NewRouter (no adapter required). type Pinger interface { @@ -29,10 +30,10 @@ type Pinger interface { // 5. auth.ResolveSession (reads session cookie, attaches user to context) — D-24 // 6. auth.Mount (gorilla/csrf — MUST come after ResolveSession, before routes) — D-24, Pitfall 7 // -// Routes: GET / · GET /healthz · GET /demo/time · GET /static/* +// Routes: GET / · GET /healthz (liveness) · GET /readyz (readiness) · GET /demo/time · GET /static/* // GET /signup (auth pages, behind RedirectIfAuthed) · POST /signup. -// staticDir is the on-disk path served at /static/*; path traversal is -// blocked by http.Dir's default behavior (T-01-08). +// staticFS is the embedded FS (or os.DirFS in tests) served at /static/*; the +// embedded FS pattern blocks path traversal at the http.FS layer (T-01-08). // // deps.Store may be nil during unit tests for Phase 1 routes (those routes // never exercise session resolution). ResolveSession guards against nil Store. @@ -44,7 +45,7 @@ type Pinger interface { // trustedOrigins is an optional list of additional origins for the CSRF // referer check (used in integration tests to allow localhost requests without // a Referer header). In production, pass no extra args — leave empty. -func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { +func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) @@ -114,11 +115,21 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps)) }) - r.Get("/healthz", HealthzHandler(pinger)) + // Liveness probe (D-12): always 200, no DB contact. + r.Get("/healthz", HealthzHandler()) + // Readiness probe (D-13): probes DB; 200 when ready, 503 when degraded. + r.Get("/readyz", ReadyzHandler(pinger)) r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() })) - fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))) - r.Get("/static/*", fs.ServeHTTP) + // Serve embedded static assets. Sub to strip the "static/" prefix from the + // embedded path so /static/tailwind.css maps to static/tailwind.css inside + // the FS. The "fs" local name is avoided to prevent shadowing the "io/fs" import. + sub, err := fs.Sub(staticFS, "static") + if err != nil { + panic("router: failed to sub static FS: " + err.Error()) + } + fileHandler := http.StripPrefix("/static/", http.FileServer(http.FS(sub))) + r.Get("/static/*", fileHandler.ServeHTTP) return r }