@nakedev/go-scaffold 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -50
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +23 -12
  6. package/dist/commands/method.js +94 -17
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +368 -64
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/module-manifest.js +2 -0
  19. package/dist/templates/observability-manifest.js +24 -0
  20. package/dist/templates/rbac-manifest.js +1 -0
  21. package/dist/templates/worker-manifest.js +23 -6
  22. package/dist/utils/auth-patcher.js +96 -21
  23. package/dist/utils/config.js +58 -10
  24. package/dist/utils/gocheck.js +57 -5
  25. package/dist/utils/golangci-patcher.js +73 -0
  26. package/dist/utils/gomod-patcher.js +53 -0
  27. package/dist/utils/main-patcher.js +58 -4
  28. package/dist/utils/marker-patch.js +125 -3
  29. package/dist/utils/method-patcher.js +97 -18
  30. package/dist/utils/module-location.js +58 -0
  31. package/dist/utils/naming.js +125 -14
  32. package/dist/utils/observability-patcher.js +107 -0
  33. package/dist/utils/openapi-patcher.js +19 -1
  34. package/dist/utils/platform-patcher.js +98 -12
  35. package/dist/utils/rbac-patcher.js +60 -10
  36. package/dist/utils/smoke-run.js +31 -0
  37. package/package.json +11 -4
  38. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  39. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  40. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  41. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  42. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  43. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  44. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  45. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  46. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  50. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  51. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  52. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  53. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  54. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  55. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  56. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  57. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  58. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  59. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  60. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  61. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  62. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  63. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  64. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  65. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  66. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  67. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  68. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  69. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  70. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  71. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  72. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  73. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  74. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  75. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  76. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  77. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  78. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  79. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  80. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  81. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  82. package/templates/create/base/.dockerignore.hbs +13 -0
  83. package/templates/create/base/.env.example.hbs +18 -9
  84. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  85. package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
  86. package/templates/create/base/.golangci.yml.hbs +27 -0
  87. package/templates/create/base/AGENTS.md.hbs +14 -12
  88. package/templates/create/base/Dockerfile.hbs +42 -0
  89. package/templates/create/base/Makefile.hbs +52 -16
  90. package/templates/create/base/README.md.hbs +61 -12
  91. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  92. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  93. package/templates/create/base/go.mod.hbs +4 -4
  94. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  95. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  96. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  97. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  98. package/templates/create/base/redocly.yaml.hbs +21 -0
  99. package/templates/create/features/docs/architecture.md.hbs +32 -11
  100. package/templates/create/features/docs/openapi.yaml.hbs +4 -9
  101. package/templates/create/features/docs/patterns.md.hbs +91 -14
  102. package/templates/create/features/docs/techstack.md.hbs +8 -3
  103. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  104. package/templates/generate/module/dto.go.hbs +8 -1
  105. package/templates/generate/module/errors.go.hbs +5 -0
  106. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  107. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  108. package/templates/generate/module/handler.go.hbs +18 -4
  109. package/templates/generate/module/handler_test.go.hbs +88 -62
  110. package/templates/generate/module/migration.down.sql.hbs +3 -1
  111. package/templates/generate/module/migration.up.sql.hbs +7 -2
  112. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  113. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  114. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  115. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  116. package/templates/generate/module/model/model.go.hbs +16 -0
  117. package/templates/generate/module/permission.up.sql.hbs +3 -1
  118. package/templates/generate/module/repository.go.hbs +60 -6
  119. package/templates/generate/module/repository_test.go.hbs +109 -0
  120. package/templates/generate/module/service.go.hbs +15 -4
  121. package/templates/generate/module/service_test.go.hbs +113 -17
  122. package/dist/commands/remove.js +0 -89
  123. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  124. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,11 +15,16 @@
15
15
  "bin"
16
16
  ],
17
17
  "type": "commonjs",
18
+ "packageManager": "pnpm@11.0.7",
18
19
  "scripts": {
19
20
  "build": "tsc",
20
21
  "dev": "tsc --watch",
21
- "test": "node scripts/smoke-test.mjs",
22
+ "test": "pnpm run test:unit && pnpm run test:integration && pnpm run test:smoke",
23
+ "test:unit": "node --test tests/unit/*.test.mjs",
24
+ "test:integration": "node --test tests/integration/*.test.mjs",
25
+ "test:smoke": "node scripts/smoke-test.mjs",
22
26
  "verify": "pnpm run build && pnpm run test",
27
+ "prepack": "rm -rf dist && pnpm run build",
23
28
  "prepublishOnly": "pnpm run verify"
24
29
  },
25
30
  "keywords": [
@@ -40,14 +45,16 @@
40
45
  "commander": "^15.0.0",
41
46
  "fs-extra": "^11.3.0",
42
47
  "handlebars": "^4.7.8",
43
- "picocolors": "^1.1.1"
48
+ "picocolors": "^1.1.1",
49
+ "pluralize": "^8.0.0"
44
50
  },
45
51
  "devDependencies": {
46
52
  "@types/fs-extra": "^11.0.4",
47
53
  "@types/node": "^24.0.0",
54
+ "@types/pluralize": "^0.0.33",
48
55
  "typescript": "^5.7.0"
49
56
  },
50
57
  "engines": {
51
- "node": ">=20"
58
+ "node": ">=22.13"
52
59
  }
53
60
  }
@@ -21,6 +21,13 @@ func errEmailTaken() *apperror.AppError {
21
21
  // errInvalidCredentials is returned for both "no such user" and "wrong
22
22
  // password" — a single generic message so a login attempt can't be used to
23
23
  // enumerate which emails have accounts.
24
+ // errTooManyAttempts is the account-level lockout, distinct from the per-IP
25
+ // RATE_LIMITED the middleware returns: this one follows the account wherever
26
+ // the attempts come from.
27
+ func errTooManyAttempts() *apperror.AppError {
28
+ return apperror.New(http.StatusTooManyRequests, "AUTH_TOO_MANY_ATTEMPTS", "too many failed attempts — try again later")
29
+ }
30
+
24
31
  func errInvalidCredentials() *apperror.AppError {
25
32
  return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
26
33
  }
@@ -2,6 +2,7 @@ package user
2
2
 
3
3
  import (
4
4
  "net/http"
5
+ "strings"
5
6
  "time"
6
7
 
7
8
  "{{goModule}}/internal/shared/httpx"
@@ -10,19 +11,26 @@ import (
10
11
 
11
12
  "github.com/gin-gonic/gin"
12
13
  "github.com/google/uuid"
13
- "github.com/redis/go-redis/v9"
14
14
  )
15
15
 
16
- const refreshCookieName = "refresh_token"
16
+ const (
17
+ refreshCookieName = "refresh_token"
18
+ // oauthStateCookieName holds the nonce that binds a Google login to the
19
+ // browser that started it — see Service.GoogleLoginURL.
20
+ oauthStateCookieName = "oauth_state"
21
+ )
17
22
 
18
23
  // go-scaffold:user-handler-consts
19
24
 
20
25
  type Handler struct {
21
- svc *Service
22
- jwtSecret string
23
- refreshTTL time.Duration
24
- cookieSecure bool
25
- rdb *redis.Client
26
+ svc *Service
27
+ jwtSecret string
28
+ refreshTTL time.Duration
29
+ cookieSecure bool
30
+ cookieSameSite string
31
+ // limiter, not a *redis.Client: which backing store counts the requests is
32
+ // decided by `add auth --store`, and this file must not care.
33
+ limiter middleware.Limiter
26
34
  // go-scaffold:user-handler-fields
27
35
  }
28
36
 
@@ -31,15 +39,17 @@ func NewHandler(
31
39
  jwtSecret string,
32
40
  refreshTTL time.Duration,
33
41
  cookieSecure bool,
34
- rdb *redis.Client,
42
+ cookieSameSite string,
43
+ limiter middleware.Limiter,
35
44
  // go-scaffold:user-handler-params
36
45
  ) *Handler {
37
46
  return &Handler{
38
- svc: svc,
39
- jwtSecret: jwtSecret,
40
- refreshTTL: refreshTTL,
41
- cookieSecure: cookieSecure,
42
- rdb: rdb,
47
+ svc: svc,
48
+ jwtSecret: jwtSecret,
49
+ refreshTTL: refreshTTL,
50
+ cookieSecure: cookieSecure,
51
+ cookieSameSite: cookieSameSite,
52
+ limiter: limiter,
43
53
  // go-scaffold:user-handler-init
44
54
  }
45
55
  }
@@ -52,12 +62,12 @@ func (h *Handler) Register(rg gin.IRouter) {
52
62
  // doesn't spend another's budget. refresh/logout/google aren't limited:
53
63
  // refresh/logout are gated by possessing a valid cookie already, and the
54
64
  // Google flow's abuse surface lives on Google's side, not ours.
55
- loginLimit := middleware.RateLimit(h.rdb, "login", 10, time.Minute)
56
- registerLimit := middleware.RateLimit(h.rdb, "register", 5, time.Minute)
57
- forgotPasswordLimit := middleware.RateLimit(h.rdb, "forgot-password", 5, time.Minute)
58
- resetPasswordLimit := middleware.RateLimit(h.rdb, "reset-password", 10, time.Minute)
59
- verifyEmailLimit := middleware.RateLimit(h.rdb, "verify-email", 10, time.Minute)
60
- resendVerificationLimit := middleware.RateLimit(h.rdb, "resend-verification", 5, time.Minute)
65
+ loginLimit := middleware.RateLimit(h.limiter, "login", 10, time.Minute)
66
+ registerLimit := middleware.RateLimit(h.limiter, "register", 5, time.Minute)
67
+ forgotPasswordLimit := middleware.RateLimit(h.limiter, "forgot-password", 5, time.Minute)
68
+ resetPasswordLimit := middleware.RateLimit(h.limiter, "reset-password", 10, time.Minute)
69
+ verifyEmailLimit := middleware.RateLimit(h.limiter, "verify-email", 10, time.Minute)
70
+ resendVerificationLimit := middleware.RateLimit(h.limiter, "resend-verification", 5, time.Minute)
61
71
 
62
72
  authGroup := rg.Group("/auth")
63
73
  authGroup.POST("/register", registerLimit, h.register)
@@ -194,16 +204,26 @@ func (h *Handler) logoutAll(c *gin.Context) {
194
204
  }
195
205
 
196
206
  func (h *Handler) googleLogin(c *gin.Context) {
197
- url, err := h.svc.GoogleLoginURL()
207
+ url, nonce, err := h.svc.GoogleLoginURL()
198
208
  if err != nil {
199
209
  c.Error(err)
200
210
  return
201
211
  }
212
+ // Lax, not Strict, and deliberately not h.cookieSameSite: the callback
213
+ // arrives as a top-level navigation from Google, i.e. cross-site, and a
214
+ // Strict cookie is not sent on one — the flow would fail every time.
215
+ c.SetSameSite(http.SameSiteLaxMode)
216
+ c.SetCookie(oauthStateCookieName, nonce, int(oauthStateTTL.Seconds()), "/", "", h.cookieSecure, true)
202
217
  c.Redirect(http.StatusFound, url)
203
218
  }
204
219
 
205
220
  func (h *Handler) googleCallback(c *gin.Context) {
206
- auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"))
221
+ nonce, _ := c.Cookie(oauthStateCookieName)
222
+ // One shot, whatever happens next.
223
+ c.SetSameSite(http.SameSiteLaxMode)
224
+ c.SetCookie(oauthStateCookieName, "", -1, "/", "", h.cookieSecure, true)
225
+
226
+ auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"), nonce)
207
227
  if err != nil {
208
228
  c.Error(err)
209
229
  return
@@ -225,11 +245,32 @@ func (h *Handler) me(c *gin.Context) {
225
245
  // go-scaffold:user-handler-funcs
226
246
 
227
247
  func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
228
- c.SetSameSite(http.SameSiteStrictMode)
248
+ c.SetSameSite(sameSiteFrom(h.cookieSameSite))
229
249
  c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
230
250
  }
231
251
 
232
252
  func (h *Handler) clearRefreshCookie(c *gin.Context) {
233
- c.SetSameSite(http.SameSiteStrictMode)
253
+ c.SetSameSite(sameSiteFrom(h.cookieSameSite))
234
254
  c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
235
255
  }
256
+
257
+ // sameSiteFrom maps COOKIE_SAMESITE onto the http constant, defaulting to the
258
+ // strictest option for anything it doesn't recognise.
259
+ //
260
+ // "strict" is right while the frontend and this API are the same site
261
+ // (localhost:3000 -> localhost:8080 is, and so is app.example.com ->
262
+ // api.example.com). A frontend on a genuinely different site — the usual
263
+ // vercel.app-plus-own-API-domain split — needs "none", because the browser
264
+ // will not attach a Strict or Lax cookie to the fetch that calls
265
+ // /auth/refresh: sessions then die at every access-token expiry with no
266
+ // error anywhere to explain it. "none" requires COOKIE_SECURE=true.
267
+ func sameSiteFrom(mode string) http.SameSite {
268
+ switch strings.ToLower(mode) {
269
+ case "none":
270
+ return http.SameSiteNoneMode
271
+ case "lax":
272
+ return http.SameSiteLaxMode
273
+ default:
274
+ return http.SameSiteStrictMode
275
+ }
276
+ }
@@ -3,6 +3,7 @@ package user
3
3
  import (
4
4
  "crypto/rand"
5
5
  "crypto/sha256"
6
+ "crypto/subtle"
6
7
  "encoding/hex"
7
8
  "time"
8
9
 
@@ -22,6 +23,8 @@ const (
22
23
  // presented where the other is expected.
23
24
  type accessClaims struct {
24
25
  Typ string `json:"typ"`
26
+ // Nonce is only set on the OAuth state token — see issueOAuthState.
27
+ Nonce string `json:"nonce,omitempty"`
25
28
  // go-scaffold:jwt-claims
26
29
  jwt.RegisteredClaims
27
30
  }
@@ -42,21 +45,37 @@ func (s *Service) issueAccessToken(
42
45
  return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
43
46
  }
44
47
 
45
- // issueOAuthState / verifyOAuthState round-trip a short-lived, stateless CSRF
46
- // token through Google's own redirect no server-side row needed, the JWT's
47
- // signature + short TTL is the whole protection.
48
- func (s *Service) issueOAuthState() (string, error) {
48
+ // issueOAuthState / verifyOAuthState round-trip a short-lived CSRF token
49
+ // through Google's own redirect. The signature and TTL are not the whole
50
+ // protection: a signed-but-unbound state is one anyone can fetch from
51
+ // GET /auth/google/login and then replay in a victim's browser, which is
52
+ // exactly the login-CSRF the state parameter exists to stop. So the token
53
+ // carries a nonce that the handler also drops in a short-lived httpOnly
54
+ // cookie, and the callback only proceeds when the two agree — the state is
55
+ // then usable in one browser only, the one that started the flow.
56
+ //
57
+ // Returns the state and the nonce to put in that cookie.
58
+ func (s *Service) issueOAuthState() (string, string, error) {
59
+ nonce, err := randomToken()
60
+ if err != nil {
61
+ return "", "", err
62
+ }
49
63
  claims := accessClaims{
50
- Typ: tokenTypeOAuthState,
64
+ Typ: tokenTypeOAuthState,
65
+ Nonce: nonce,
51
66
  RegisteredClaims: jwt.RegisteredClaims{
52
67
  IssuedAt: jwt.NewNumericDate(time.Now()),
53
68
  ExpiresAt: jwt.NewNumericDate(time.Now().Add(oauthStateTTL)),
54
69
  },
55
70
  }
56
- return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
71
+ state, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
72
+ if err != nil {
73
+ return "", "", err
74
+ }
75
+ return state, nonce, nil
57
76
  }
58
77
 
59
- func (s *Service) verifyOAuthState(raw string) error {
78
+ func (s *Service) verifyOAuthState(raw, nonce string) error {
60
79
  var claims accessClaims
61
80
  _, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
62
81
  return []byte(s.jwtSecret), nil
@@ -64,6 +83,11 @@ func (s *Service) verifyOAuthState(raw string) error {
64
83
  if err != nil || claims.Typ != tokenTypeOAuthState {
65
84
  return errInvalidToken()
66
85
  }
86
+ // A missing cookie is a mismatch, not a pass — otherwise stripping the
87
+ // cookie is all it takes to get the old, unbound behavior back.
88
+ if nonce == "" || subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
89
+ return errInvalidToken()
90
+ }
67
91
  return nil
68
92
  }
69
93
 
@@ -0,0 +1,39 @@
1
+ package model
2
+
3
+ import (
4
+ "time"
5
+
6
+ "github.com/google/uuid"
7
+ )
8
+
9
+ // AuthToken is one short-lived token — the Postgres equivalent of the keys the
10
+ // Redis store would hold. Only the SHA-256 hash of the raw token is stored, so
11
+ // a database dump never yields a usable token.
12
+ //
13
+ // `Kind` mirrors the Redis key prefixes one-for-one, deliberately:
14
+ //
15
+ // refresh an active refresh token
16
+ // refresh_used a tombstone left by rotation, so replaying a rotated-out
17
+ // token is detectable as reuse rather than merely unknown
18
+ // pwreset password reset, consumed once
19
+ // emailverify email verification, consumed once
20
+ //
21
+ // Keeping the tombstone a separate row (rather than a used_at column on the
22
+ // refresh row) is what preserves logout's semantics: logout deletes the row
23
+ // outright and leaves no tombstone, so someone replaying a logged-out token
24
+ // gets a plain rejection instead of tripping reuse detection and nuking every
25
+ // session the user has.
26
+ //
27
+ // No foreign key to users on purpose: GORM's AutoMigrate (dev) wouldn't create
28
+ // one from these tags, and a constraint that exists in production but not in
29
+ // development is the exact mismatch that stops the app booting. A token whose
30
+ // user is gone simply fails the lookup that follows.
31
+ type AuthToken struct {
32
+ TokenHash string `gorm:"primaryKey;type:text"`
33
+ UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
34
+ Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
35
+ ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
36
+ CreatedAt time.Time
37
+ }
38
+
39
+ func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
@@ -26,3 +26,6 @@ type Identity struct {
26
26
  CreatedAt time.Time `json:"created_at"`
27
27
  UpdatedAt time.Time `json:"updated_at"`
28
28
  }
29
+
30
+ // TableName — see User.TableName; same schema, same reasoning.
31
+ func (Identity) TableName() string { return "user_svc.identities" }
@@ -0,0 +1,26 @@
1
+ package model
2
+
3
+ import "time"
4
+
5
+ // LoginThrottle is the failed-attempt counter that makes lockout survive a
6
+ // deploy and mean the same thing on every replica. It is not the rate limiter
7
+ // — that one counts requests per IP and is allowed to be approximate. This
8
+ // counts failures per account, and per OWASP that is the control that actually
9
+ // stops credential stuffing: an attacker with a proxy pool keeps per-IP volume
10
+ // under any threshold you set, but they cannot spread attempts against one
11
+ // account across accounts.
12
+ //
13
+ // EmailHash, not the address: the counter has to be keyed on what the caller
14
+ // typed whether or not an account exists — otherwise "did this get throttled"
15
+ // answers "does this account exist" — and hashing means this table never
16
+ // becomes a directory of who has signed up.
17
+ type LoginThrottle struct {
18
+ EmailHash string `gorm:"primaryKey;type:text"`
19
+ Failures int `gorm:"not null;default:0"`
20
+ // nil until the free attempts are spent; afterwards it moves further out
21
+ // with each failure.
22
+ LockedUntil *time.Time
23
+ UpdatedAt time.Time
24
+ }
25
+
26
+ func (LoginThrottle) TableName() string { return "user_svc.login_throttle" }
@@ -12,7 +12,10 @@ import (
12
12
  // resolving to one account.
13
13
  type User struct {
14
14
  ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
15
- Email string `json:"email" gorm:"uniqueIndex;not null"`
15
+ // index named explicitly, and named the same in create_users.up.sql: an
16
+ // anonymous `uniqueIndex` makes GORM invent one, and AutoMigrate then
17
+ // tries to DROP the differently-named constraint the migration created.
18
+ Email string `json:"email" gorm:"uniqueIndex:idx_users_email;not null"`
16
19
  Name string `json:"name"`
17
20
  AvatarURL string `json:"avatar_url"`
18
21
  EmailVerified bool `json:"email_verified"`
@@ -20,3 +23,8 @@ type User struct {
20
23
  CreatedAt time.Time `json:"created_at"`
21
24
  UpdatedAt time.Time `json:"updated_at"`
22
25
  }
26
+
27
+ // TableName pins this to user_svc rather than GORM's default inflection
28
+ // ("users", schema-less) — every domain gets its own schema, see
29
+ // docs/architect/patterns.md.
30
+ func (User) TableName() string { return "user_svc.users" }
@@ -2,8 +2,11 @@ package user
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
6
+ "time"
5
7
 
6
8
  "{{goModule}}/internal/app/user/model"
9
+ "{{goModule}}/internal/shared/tx"
7
10
 
8
11
  "github.com/google/uuid"
9
12
  "gorm.io/gorm"
@@ -19,7 +22,7 @@ func NewRepository(db *gorm.DB) *Repository {
19
22
 
20
23
  func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
21
24
  var u model.User
22
- if err := r.db.WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
25
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
23
26
  return nil, err
24
27
  }
25
28
  return &u, nil
@@ -27,25 +30,25 @@ func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User
27
30
 
28
31
  func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
29
32
  var u model.User
30
- if err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
33
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
31
34
  return nil, err
32
35
  }
33
36
  return &u, nil
34
37
  }
35
38
 
36
39
  func (r *Repository) UpdateUser(ctx context.Context, u *model.User) error {
37
- return r.db.WithContext(ctx).Save(u).Error
40
+ return tx.From(ctx, r.db).WithContext(ctx).Save(u).Error
38
41
  }
39
42
 
40
43
  func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.User, error) {
41
44
  var items []model.User
42
- err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
45
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
43
46
  return items, err
44
47
  }
45
48
 
46
49
  func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error) {
47
50
  var i model.Identity
48
- if err := r.db.WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
51
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
49
52
  return nil, err
50
53
  }
51
54
  return &i, nil
@@ -53,7 +56,7 @@ func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provide
53
56
 
54
57
  func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error) {
55
58
  var i model.Identity
56
- if err := r.db.WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
59
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
57
60
  return nil, err
58
61
  }
59
62
  return &i, nil
@@ -63,22 +66,72 @@ func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider mod
63
66
  // Google linking onto an account that registered with a password first) —
64
67
  // see CreateUserWithIdentity for the "brand new user" case.
65
68
  func (r *Repository) CreateIdentity(ctx context.Context, i *model.Identity) error {
66
- return r.db.WithContext(ctx).Create(i).Error
69
+ return tx.From(ctx, r.db).WithContext(ctx).Create(i).Error
67
70
  }
68
71
 
69
72
  func (r *Repository) UpdateIdentity(ctx context.Context, i *model.Identity) error {
70
- return r.db.WithContext(ctx).Save(i).Error
73
+ return tx.From(ctx, r.db).WithContext(ctx).Save(i).Error
71
74
  }
72
75
 
73
76
  // CreateUserWithIdentity inserts the profile and its first login method in
74
77
  // one transaction — a user with no identity at all can't log in any way, so
75
78
  // the two rows always exist together or not at all.
76
79
  func (r *Repository) CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error {
77
- return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
78
- if err := tx.Create(u).Error; err != nil {
80
+ return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
81
+ if err := t.Create(u).Error; err != nil {
79
82
  return err
80
83
  }
81
84
  i.UserID = u.ID
82
- return tx.Create(i).Error
85
+ return t.Create(i).Error
83
86
  })
84
87
  }
88
+
89
+ // --- failed-login throttle -------------------------------------------------
90
+ //
91
+ // Keyed on a caller-supplied hash rather than a user id, because the counter
92
+ // has to work for addresses that have no account — see model.LoginThrottle.
93
+
94
+ // LoginLockedUntil reports when this key stops being locked out — zero time
95
+ // when it isn't. Read on every login attempt, so it stays a primary-key hit.
96
+ func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Time, error) {
97
+ var row model.LoginThrottle
98
+ err := tx.From(ctx, r.db).WithContext(ctx).Where("email_hash = ?", key).Take(&row).Error
99
+ if errors.Is(err, gorm.ErrRecordNotFound) {
100
+ return time.Time{}, nil
101
+ }
102
+ if err != nil || row.LockedUntil == nil {
103
+ return time.Time{}, err
104
+ }
105
+ return *row.LockedUntil, nil
106
+ }
107
+
108
+ // RecordLoginFailure bumps the counter and pushes the lock further out,
109
+ // doubling each time past freeAttempts and never exceeding maxLock.
110
+ //
111
+ // One statement, so two attempts racing cannot both read the same count and
112
+ // write the same lock — which would let an attacker keep the backoff pinned at
113
+ // its first step by running attempts in parallel. Computing the interval in
114
+ // SQL is what buys that; a read-then-write in Go would need a transaction and
115
+ // a row lock to be equally safe.
116
+ func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
117
+ return tx.From(ctx, r.db).WithContext(ctx).Exec(`
118
+ INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
119
+ VALUES (?, 1, NULL, now())
120
+ ON CONFLICT (email_hash) DO UPDATE SET
121
+ failures = t.failures + 1,
122
+ locked_until = CASE
123
+ WHEN t.failures + 1 <= ? THEN NULL
124
+ ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
125
+ END,
126
+ updated_at = now()`,
127
+ key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
128
+ }
129
+
130
+ // ClearLoginFailures drops the row on a successful login, so someone who
131
+ // mistypes twice and then gets in starts clean rather than creeping toward a
132
+ // lockout over weeks.
133
+ func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
134
+ return tx.From(ctx, r.db).WithContext(ctx).
135
+ Where("email_hash = ?", key).
136
+ Delete(&model.LoginThrottle{}).Error
137
+ }