Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions TEMPLATE_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ Tracks all identified gaps from the June 2026 template analysis. Issues live in

| # | Issue | Status |
|---|-------|--------|
| [#50](https://github.com/GRACENOBLE/fullstack-template/issues/50) | `infra` Root dev script to start all services with one command | 🔁 In review |
| [#51](https://github.com/GRACENOBLE/fullstack-template/issues/51) | `backend` Add `.golangci.yml` linter config | 🔁 In review |
| [#52](https://github.com/GRACENOBLE/fullstack-template/issues/52) | `mobile` Add ktlint and integrate into CI | 🔁 In review |
| [#53](https://github.com/GRACENOBLE/fullstack-template/issues/53) | `infra` Renovate / Dependabot for automated dependency updates | 🔁 In review |
| [#54](https://github.com/GRACENOBLE/fullstack-template/issues/54) | `infra` First-run setup script for new contributors | 🔁 In review |
| [#55](https://github.com/GRACENOBLE/fullstack-template/issues/55) | `backend` Swagger generation check in CI (fail if stale) | 🔁 In review |
| [#56](https://github.com/GRACENOBLE/fullstack-template/issues/56) | `mobile` Loading state and skeleton screen pattern | 🔁 In review |
| [#57](https://github.com/GRACENOBLE/fullstack-template/issues/57) | `mobile` Error state and retry UI pattern (`UiState<T>` sealed class) | 🔁 In review |
| [#58](https://github.com/GRACENOBLE/fullstack-template/issues/58) | `web` Data table with sorting, filtering, and pagination (TanStack Table) | 🔁 In review |
| [#50](https://github.com/GRACENOBLE/fullstack-template/issues/50) | `infra` Root dev script to start all services with one command | ✅ Done (merged) |
| [#51](https://github.com/GRACENOBLE/fullstack-template/issues/51) | `backend` Add `.golangci.yml` linter config | ✅ Done (merged) |
| [#52](https://github.com/GRACENOBLE/fullstack-template/issues/52) | `mobile` Add ktlint and integrate into CI | ✅ Done (merged) |
| [#53](https://github.com/GRACENOBLE/fullstack-template/issues/53) | `infra` Renovate / Dependabot for automated dependency updates | ✅ Done (merged) |
| [#54](https://github.com/GRACENOBLE/fullstack-template/issues/54) | `infra` First-run setup script for new contributors | ✅ Done (merged) |
| [#55](https://github.com/GRACENOBLE/fullstack-template/issues/55) | `backend` Swagger generation check in CI (fail if stale) | ✅ Done (merged) |
| [#56](https://github.com/GRACENOBLE/fullstack-template/issues/56) | `mobile` Loading state and skeleton screen pattern | ✅ Done (merged) |
| [#57](https://github.com/GRACENOBLE/fullstack-template/issues/57) | `mobile` Error state and retry UI pattern (`UiState<T>` sealed class) | ✅ Done (merged) |
| [#58](https://github.com/GRACENOBLE/fullstack-template/issues/58) | `web` Data table with sorting, filtering, and pagination (TanStack Table) | ✅ Done (merged) |

---

Expand Down Expand Up @@ -64,4 +64,4 @@ Tracks all identified gaps from the June 2026 template analysis. Issues live in

---

_Last updated: 2026-06-25 — #50–#58 implemented (first-week friction), PR pending._
_Last updated: 2026-06-25 — PR #66 merged, all first-week friction issues done. Next: medium priority (#59–#62)._
8 changes: 7 additions & 1 deletion backend/docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,17 @@ Unmatched routes (404s with no Gin `FullPath()`) are recorded under the path lab

## LocalNetworkOnly

`LocalNetworkOnly() gin.HandlerFunc` aborts with `403 Forbidden` when the client IP is neither a loopback address nor an RFC 1918 private address. In release mode, `RegisterRoutes` applies it as a per-route middleware on `/metrics` so the Prometheus scrape endpoint is reachable from the internal network but not from external clients.
`LocalNetworkOnly() gin.HandlerFunc` aborts with `403 Forbidden` when the client IP is neither a loopback address nor an RFC 1918 private address. `RegisterRoutes` applies it in two places:

1. `/metrics` — in release mode only, so the Prometheus scrape endpoint is reachable from the internal network but not from external clients.
2. `/debug/pprof/*` — unconditionally (both debug and release modes), applied as a group middleware so all pprof endpoints are always restricted to loopback/private addresses.

```go
// release mode only:
r.GET("/metrics", middleware.LocalNetworkOnly(), gin.WrapH(promhttp.Handler()))

// all modes:
debug := r.Group("/debug/pprof", middleware.LocalNetworkOnly())
```

## GeoFromRequest
Expand Down
21 changes: 21 additions & 0 deletions backend/docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ sources:
- internal/transport/handlers/health_handler.go
- internal/transport/handlers/auth_handler.go
- internal/transport/handlers/me_handler.go
- internal/transport/handlers/pprof_handler_test.go
- internal/transport/handlers/validation.go
- internal/transport/handlers/response.go
- internal/transport/middleware/logger.go
Expand Down Expand Up @@ -130,6 +131,18 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string, allow

r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

// /debug/pprof — always restricted to loopback / RFC 1918 (never public).
debug := r.Group("/debug/pprof", middleware.LocalNetworkOnly())
{
debug.GET("/", gin.WrapF(pprof.Index))
debug.GET("/cmdline", gin.WrapF(pprof.Cmdline))
debug.GET("/profile", gin.WrapF(pprof.Profile))
debug.GET("/symbol", gin.WrapF(pprof.Symbol))
debug.POST("/symbol", gin.WrapF(pprof.Symbol))
debug.GET("/trace", gin.WrapF(pprof.Trace))
debug.GET("/:profile", gin.WrapF(pprof.Index))
}

// Asynqmon job-monitoring UI — debug/local only.
if gin.Mode() == gin.DebugMode && h.queueUI != nil {
r.GET("/admin/queues", gin.WrapH(h.queueUI))
Expand Down Expand Up @@ -192,6 +205,13 @@ Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH.
| GET | `/ws` | `?token=` query param | `WsHandler` — upgrades to WebSocket; 401 when token missing/invalid | `ws_handler.go` |
| GET | `/metrics` | `LocalNetworkOnly()` in release mode | Prometheus scrape endpoint; unrestricted in debug mode | `routes.go` |
| GET | `/swagger/*any` | none | Swagger UI | `routes.go` |
| GET | `/debug/pprof/` | `LocalNetworkOnly()` (always) | pprof index — `net/http/pprof.Index` | `routes.go` |
| GET | `/debug/pprof/cmdline` | `LocalNetworkOnly()` (always) | pprof cmdline | `routes.go` |
| GET | `/debug/pprof/profile` | `LocalNetworkOnly()` (always) | CPU profile | `routes.go` |
| GET | `/debug/pprof/symbol` | `LocalNetworkOnly()` (always) | pprof symbol lookup | `routes.go` |
| POST | `/debug/pprof/symbol` | `LocalNetworkOnly()` (always) | pprof symbol lookup | `routes.go` |
| GET | `/debug/pprof/trace` | `LocalNetworkOnly()` (always) | execution trace | `routes.go` |
| GET | `/debug/pprof/:profile` | `LocalNetworkOnly()` (always) | named profile (heap, goroutine, etc.) | `routes.go` |
| GET | `/admin/queues` | none (debug mode only) | Asynqmon job-monitoring UI | `routes.go` |
| GET | `/api/v1/me` | FirebaseAuth header | `MeHandler` — returns verified `FirebaseToken` claims | `auth_handler.go` |
| PATCH | `/api/v1/me` | FirebaseAuth header | `UpdateMeHandler` — upserts user profile; returns `domain.User` | `me_handler.go` |
Expand All @@ -204,6 +224,7 @@ Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH.
FCM routes are only registered when `h.fcmTokenRepo != nil` (i.e., `FIREBASE_PROJECT_ID` is set).
Storage routes are only registered when `h.storageService != nil` (i.e., `R2_ACCOUNT_ID` is set).
`PATCH /api/v1/me` and `DELETE /api/v1/me` are registered when `h.userRepo != nil` (always wired).
`/debug/pprof/*` routes are always registered (in both debug and release modes) but are unconditionally gated by `LocalNetworkOnly()` — they are never reachable from external clients. They are intentionally excluded from Swagger annotations because `gin.WrapF` handlers carry no swaggo metadata.

## Graceful shutdown
Wired in `cmd/api/main.go` via `signal.NotifyContext` for SIGINT/SIGTERM.
Expand Down
66 changes: 66 additions & 0 deletions backend/internal/transport/handlers/pprof_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package handlers

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestPprofIndex_LoopbackAllowed(t *testing.T) {
h := &Handler{}
handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"})

req := httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil)
req.RemoteAddr = "127.0.0.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Errorf("expected 200 from loopback, got %d", w.Code)
}
}

func TestPprofIndex_PublicIPForbidden(t *testing.T) {
h := &Handler{}
handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"})

req := httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil)
req.RemoteAddr = "8.8.8.8:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)

if w.Code != http.StatusForbidden {
t.Errorf("expected 403 from public IP, got %d", w.Code)
}
}

func TestPprofIndex_XForwardedForSpoofingBlocked(t *testing.T) {
h := &Handler{}
handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"})

// Attacker connects from a public IP but spoofs X-Forwarded-For: 127.0.0.1.
// LocalNetworkOnly uses RemoteAddr, not ClientIP(), so spoofing must not bypass the check.
req := httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil)
req.RemoteAddr = "8.8.8.8:12345"
req.Header.Set("X-Forwarded-For", "127.0.0.1")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)

if w.Code != http.StatusForbidden {
t.Errorf("expected 403 when X-Forwarded-For is spoofed, got %d (spoofing bypass!)", w.Code)
}
}

func TestPprofHeap_LoopbackAllowed(t *testing.T) {
h := &Handler{}
handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"})

req := httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil)
req.RemoteAddr = "127.0.0.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Errorf("expected 200 for /debug/pprof/heap from loopback, got %d", w.Code)
}
}
13 changes: 13 additions & 0 deletions backend/internal/transport/handlers/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"net/http"
"net/http/pprof"

"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -55,6 +56,18 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string, allow

r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

// /debug/pprof — always restricted to loopback / RFC 1918 (never public).
debug := r.Group("/debug/pprof", middleware.LocalNetworkOnly())
{
debug.GET("/", gin.WrapF(pprof.Index))
debug.GET("/cmdline", gin.WrapF(pprof.Cmdline))
debug.GET("/profile", gin.WrapF(pprof.Profile))
debug.GET("/symbol", gin.WrapF(pprof.Symbol))
debug.POST("/symbol", gin.WrapF(pprof.Symbol))
debug.GET("/trace", gin.WrapF(pprof.Trace))
debug.GET("/:profile", gin.WrapF(pprof.Index))
}

Comment thread
GRACENOBLE marked this conversation as resolved.
// Asynqmon job-monitoring UI — debug/local only.
if gin.Mode() == gin.DebugMode && h.queueUI != nil {
r.GET("/admin/queues", gin.WrapH(h.queueUI))
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/transport/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ func FirebaseAuth(verifier usecase.FirebaseTokenVerifier) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid Authorization header"})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "missing or invalid Authorization header"}})
return
}
idToken := strings.TrimPrefix(header, "Bearer ")

claims, err := verifier.VerifyIDToken(c.Request.Context(), idToken)
if err != nil || claims == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "invalid or expired token"}})
return
}

Expand Down
7 changes: 7 additions & 0 deletions backend/internal/transport/middleware/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -34,6 +35,12 @@ func TestFirebaseAuth_MissingHeader(t *testing.T) {
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
// Error body must use the nested envelope: {"error":{"code":"...","message":"..."}}
// so the mobile client's ApiErrorResponse can deserialise it.
body := w.Body.String()
if !strings.Contains(body, `"code"`) {
t.Errorf("expected nested error envelope with 'code' key, got: %s", body)
}
}

func TestFirebaseAuth_NonBearerHeader(t *testing.T) {
Expand Down
13 changes: 11 additions & 2 deletions backend/internal/transport/middleware/local_network.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@ import (

// LocalNetworkOnly rejects requests that originate outside loopback or RFC 1918
// private address space. Use this to restrict internal-only endpoints (e.g.
// /metrics) from being reachable by external clients in production.
// /metrics, /debug/pprof) from being reachable by external clients in production.
//
// Uses RemoteAddr (the TCP peer address) rather than c.ClientIP() to prevent
// X-Forwarded-For spoofing — an external caller cannot forge their RemoteAddr.
func LocalNetworkOnly() gin.HandlerFunc {
return func(c *gin.Context) {
ip := net.ParseIP(c.ClientIP())
// RemoteAddr is "host:port"; strip the port before parsing.
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
if err != nil {
c.AbortWithStatus(http.StatusForbidden)
return
}
ip := net.ParseIP(host)
if ip == nil || (!ip.IsLoopback() && !ip.IsPrivate()) {
c.AbortWithStatus(http.StatusForbidden)
return
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.company.template.settings

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.company.template.ui.theme.TemplateTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SettingsScreenTest {
@get:Rule
val composeTestRule = createComposeRule()

@Test
fun settingsScreen_displaysDisplayNameAndEmail() {
composeTestRule.setContent {
TemplateTheme {
SettingsScreen(
displayName = "Alice Example",
email = "alice@example.com",
onSignOut = {},
)
}
}
composeTestRule.onNodeWithText("Alice Example").assertIsDisplayed()
composeTestRule.onNodeWithText("alice@example.com").assertIsDisplayed()
}

@Test
fun settingsScreen_clickSignOut_invokesCallback() {
var signOutCalled = false
composeTestRule.setContent {
TemplateTheme {
SettingsScreen(
displayName = "Alice Example",
email = "alice@example.com",
onSignOut = { signOutCalled = true },
)
}
}
composeTestRule.onNodeWithText("Sign out").performClick()
assertTrue(signOutCalled)
}

@Test
fun settingsScreen_nullDisplayNameAndEmail_showsDashes() {
composeTestRule.setContent {
TemplateTheme {
SettingsScreen(
displayName = null,
email = null,
onSignOut = {},
)
}
}
// Both displayName and email fall back to "—", so two nodes match.
composeTestRule.onAllNodesWithText("—")[0].assertIsDisplayed()
composeTestRule.onAllNodesWithText("—")[1].assertIsDisplayed()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading