@nakedev/go-scaffold 0.1.2 → 0.1.4

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 (131) hide show
  1. package/README.md +93 -14
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +61 -2
  5. package/dist/commands/method.js +66 -15
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +36 -20
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +71 -7
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/module-manifest.js +2 -0
  16. package/dist/templates/rbac-manifest.js +17 -0
  17. package/dist/templates/worker-manifest.js +12 -0
  18. package/dist/utils/auth-patcher.js +96 -0
  19. package/dist/utils/gocheck.js +65 -0
  20. package/dist/utils/main-patcher.js +8 -1
  21. package/dist/utils/method-patcher.js +80 -16
  22. package/dist/utils/migrations.js +30 -8
  23. package/dist/utils/module-location.js +22 -0
  24. package/dist/utils/naming.js +75 -12
  25. package/dist/utils/openapi-patcher.js +35 -1
  26. package/dist/utils/platform-patcher.js +59 -0
  27. package/dist/utils/rbac-patcher.js +277 -0
  28. package/dist/utils/smoke-run.js +31 -0
  29. package/dist/utils/version.js +24 -0
  30. package/package.json +15 -6
  31. package/scripts/smoke-test.mjs +2058 -0
  32. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  33. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  35. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  38. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  39. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  41. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  42. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  43. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  44. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  45. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  46. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  47. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  48. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  49. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  50. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  51. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  52. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  53. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  54. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  55. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  56. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  57. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  58. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  59. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  60. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  61. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  62. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  63. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  64. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  65. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  66. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  67. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  68. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  69. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  70. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  71. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  72. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  73. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  74. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  75. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  76. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  77. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  78. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  79. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  80. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  81. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  82. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  83. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  84. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  85. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  86. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  87. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  88. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  89. package/templates/create/base/.env.example.hbs +20 -0
  90. package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
  91. package/templates/create/base/.gitignore.hbs +2 -0
  92. package/templates/create/base/AGENTS.md.hbs +10 -12
  93. package/templates/create/base/Makefile.hbs +39 -8
  94. package/templates/create/base/README.md.hbs +52 -12
  95. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  96. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  97. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  98. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  99. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  100. package/templates/create/base/migrations/embed.go.hbs +15 -0
  101. package/templates/create/features/docs/architecture.md.hbs +22 -0
  102. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  103. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  104. package/templates/create/features/docs/openapi.yaml.hbs +17 -5
  105. package/templates/create/features/docs/patterns.md.hbs +9 -6
  106. package/templates/create/features/docs/techstack.md.hbs +3 -0
  107. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  108. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  109. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  110. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  111. package/templates/generate/module/handler.go.hbs +37 -6
  112. package/templates/generate/module/handler_test.go.hbs +113 -51
  113. package/templates/generate/module/migration.down.sql.hbs +1 -1
  114. package/templates/generate/module/migration.up.sql.hbs +1 -1
  115. package/templates/generate/module/minimal/handler.go.hbs +28 -4
  116. package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
  117. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  118. package/templates/generate/module/model/model.go.hbs +7 -0
  119. package/templates/generate/module/permission.down.sql.hbs +5 -0
  120. package/templates/generate/module/permission.up.sql.hbs +4 -0
  121. package/templates/generate/module/repository_test.go.hbs +79 -0
  122. package/templates/generate/module/service.go.hbs +6 -4
  123. package/templates/generate/module/service_test.go.hbs +68 -17
  124. package/tests/integration/default-module.test.mjs +46 -0
  125. package/tests/integration/generator-naming.test.mjs +81 -0
  126. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  127. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  128. package/tests/integration/remove-module.test.mjs +58 -0
  129. package/tests/unit/naming.test.mjs +94 -0
  130. package/tests/unit/smoke-isolation.test.mjs +35 -0
  131. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,76 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "flag"
6
+ "log/slog"
7
+ "os"
8
+
9
+ "{{goModule}}/internal/app/user"
10
+ "{{goModule}}/internal/platform/database"
11
+ "{{goModule}}/internal/shared/config"
12
+ // go-scaffold:seed-imports
13
+ )
14
+
15
+ // cmd/seed is a one-off operational task, like `migrate` — not part of the
16
+ // API server's runtime, so it's a separate binary rather than a flag on
17
+ // cmd/api. tokens/mailer are nil: EnsureUser never touches them (no login
18
+ // or email flow runs during seeding).
19
+ func main() {
20
+ withFixtures := flag.Bool("fixtures", false, "also seed sample dev users — do not use against a real environment")
21
+ flag.Parse()
22
+
23
+ cfg := config.Load()
24
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
25
+
26
+ db, err := database.Open(cfg)
27
+ if err != nil {
28
+ logger.Error("open db", "error", err)
29
+ os.Exit(1)
30
+ }
31
+
32
+ // go-scaffold:seed-services
33
+ svc := user.NewService(
34
+ user.NewRepository(db), nil, nil, cfg,
35
+ // go-scaffold:seed-user-service-args
36
+ )
37
+ ctx := context.Background()
38
+
39
+ if email := os.Getenv("SEED_ADMIN_EMAIL"); email != "" {
40
+ password := os.Getenv("SEED_ADMIN_PASSWORD")
41
+ if password == "" {
42
+ logger.Error("SEED_ADMIN_PASSWORD is required when SEED_ADMIN_EMAIL is set")
43
+ os.Exit(1)
44
+ }
45
+ name := os.Getenv("SEED_ADMIN_NAME")
46
+ if name == "" {
47
+ name = "Admin"
48
+ }
49
+ u, err := svc.EnsureUser(ctx, email, password, name)
50
+ if err != nil {
51
+ logger.Error("seed admin user", "error", err)
52
+ os.Exit(1)
53
+ }
54
+ logger.Info("admin user ready", "email", u.Email, "id", u.ID)
55
+ // go-scaffold:seed-admin-role
56
+ }
57
+
58
+ if *withFixtures {
59
+ seedFixtures(ctx, svc, logger)
60
+ }
61
+ }
62
+
63
+ func seedFixtures(ctx context.Context, svc *user.Service, logger *slog.Logger) {
64
+ fixtures := []struct{ email, name string }{
65
+ {email: "dev.one@example.com", name: "Dev One"},
66
+ {email: "dev.two@example.com", name: "Dev Two"},
67
+ }
68
+ for _, f := range fixtures {
69
+ u, err := svc.EnsureUser(ctx, f.email, "password123", f.name)
70
+ if err != nil {
71
+ logger.Error("seed fixture user", "email", f.email, "error", err)
72
+ continue
73
+ }
74
+ logger.Info("fixture user ready", "email", u.Email, "id", u.ID)
75
+ }
76
+ }
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Request a password reset link
3
+ description: Always returns the same message whether or not the email exists, to avoid leaking which emails are registered.
4
+ operationId: forgotPassword
5
+ tags: [auth]
6
+ security: []
7
+ requestBody:
8
+ required: true
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/ForgotPasswordInput' }
12
+ responses:
13
+ "200":
14
+ description: ok
15
+ content:
16
+ application/json:
17
+ schema: { $ref: './schemas.yaml#/MessageResponse' }
18
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
19
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,22 @@
1
+ get:
2
+ summary: Google OAuth callback
3
+ description: Redirect target Google sends the browser back to after consent — not called directly by API clients.
4
+ operationId: googleCallback
5
+ tags: [auth]
6
+ security: []
7
+ parameters:
8
+ - name: code
9
+ in: query
10
+ required: true
11
+ schema: { type: string }
12
+ - name: state
13
+ in: query
14
+ required: true
15
+ schema: { type: string }
16
+ responses:
17
+ "200":
18
+ description: ok, refresh_token set as an httpOnly cookie
19
+ content:
20
+ application/json:
21
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
22
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,7 @@
1
+ get:
2
+ summary: Start the Google OAuth login flow
3
+ operationId: googleLogin
4
+ tags: [auth]
5
+ security: []
6
+ responses:
7
+ "302": { description: redirect to Google's consent screen }
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Log in with email and password
3
+ operationId: loginUser
4
+ tags: [auth]
5
+ security: []
6
+ requestBody:
7
+ required: true
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/LoginInput' }
11
+ responses:
12
+ "200":
13
+ description: ok, refresh_token set as an httpOnly cookie
14
+ content:
15
+ application/json:
16
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
17
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
18
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
19
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,8 @@
1
+ post:
2
+ summary: Log out the current session
3
+ description: Revokes the refresh_token cookie's session and clears the cookie. Always succeeds, even with no cookie.
4
+ operationId: logoutUser
5
+ tags: [auth]
6
+ security: []
7
+ responses:
8
+ "204": { description: logged out }
@@ -0,0 +1,15 @@
1
+ post:
2
+ summary: Rotate the refresh token cookie for a new access token
3
+ description: >-
4
+ Reads refresh_token from the httpOnly cookie (no request body). Reusing an
5
+ already-rotated-out token revokes the whole session family.
6
+ operationId: refreshToken
7
+ tags: [auth]
8
+ security: []
9
+ responses:
10
+ "200":
11
+ description: ok, a new refresh_token cookie is set
12
+ content:
13
+ application/json:
14
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
15
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,19 @@
1
+ post:
2
+ summary: Register a new account
3
+ operationId: registerUser
4
+ tags: [auth]
5
+ security: []
6
+ requestBody:
7
+ required: true
8
+ content:
9
+ application/json:
10
+ schema: { $ref: './schemas.yaml#/RegisterInput' }
11
+ responses:
12
+ "201":
13
+ description: created, refresh_token set as an httpOnly cookie
14
+ content:
15
+ application/json:
16
+ schema: { $ref: './schemas.yaml#/AuthResponse' }
17
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
18
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
19
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,16 @@
1
+ post:
2
+ summary: Reset a password using a reset token
3
+ description: The token is one-time use (consumed on success) and also revokes every existing refresh token for the account.
4
+ operationId: resetPassword
5
+ tags: [auth]
6
+ security: []
7
+ requestBody:
8
+ required: true
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/ResetPasswordInput' }
12
+ responses:
13
+ "204": { description: password reset }
14
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
15
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
16
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,58 @@
1
+ RegisterInput:
2
+ type: object
3
+ required: [email, password, name]
4
+ properties:
5
+ email: { type: string, format: email }
6
+ password: { type: string, format: password, minLength: 8 }
7
+ name: { type: string }
8
+
9
+ LoginInput:
10
+ type: object
11
+ required: [email, password]
12
+ properties:
13
+ email: { type: string, format: email }
14
+ password: { type: string, format: password }
15
+
16
+ ForgotPasswordInput:
17
+ type: object
18
+ required: [email]
19
+ properties:
20
+ email: { type: string, format: email }
21
+
22
+ ResetPasswordInput:
23
+ type: object
24
+ required: [token, new_password]
25
+ properties:
26
+ token: { type: string }
27
+ new_password: { type: string, format: password, minLength: 8 }
28
+
29
+ VerifyEmailInput:
30
+ type: object
31
+ required: [token]
32
+ properties:
33
+ token: { type: string }
34
+
35
+ # access_token in the body; refresh_token is set as an httpOnly cookie, never
36
+ # echoed back — see toCookieResponse in internal/app/user/dto.go.
37
+ AuthResponse:
38
+ type: object
39
+ properties:
40
+ access_token: { type: string }
41
+ token_type: { type: string, example: Bearer }
42
+ expires_in: { type: integer, description: seconds until access_token expires }
43
+
44
+ MessageResponse:
45
+ type: object
46
+ properties:
47
+ message: { type: string }
48
+
49
+ MeResponse:
50
+ type: object
51
+ properties:
52
+ id: { type: string, format: uuid }
53
+ email: { type: string, format: email }
54
+ name: { type: string }
55
+ avatar_url: { type: string }
56
+ email_verified: { type: boolean }
57
+ # go-scaffold:me-response-fields
58
+ created_at: { type: string, format: date-time }
@@ -0,0 +1,9 @@
1
+ post:
2
+ summary: Log out every session for the current user
3
+ description: Revokes every refresh token for the caller, not just the current one, and clears this request's cookie too.
4
+ operationId: logoutAllSessions
5
+ tags: [users]
6
+ security: [{ bearerAuth: [] }]
7
+ responses:
8
+ "204": { description: all sessions logged out }
9
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,10 @@
1
+ post:
2
+ summary: Resend the email verification link
3
+ operationId: resendVerification
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ responses:
7
+ "204": { description: verification email sent }
8
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
9
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
10
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,12 @@
1
+ get:
2
+ summary: Get the current user
3
+ operationId: getMe
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ responses:
7
+ "200":
8
+ description: ok
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/MeResponse' }
12
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,16 @@
1
+ post:
2
+ summary: Verify an email address using a verification token
3
+ description: The token is one-time use (GETDEL), reusing an already-consumed token fails.
4
+ operationId: verifyEmail
5
+ tags: [auth]
6
+ security: []
7
+ requestBody:
8
+ required: true
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/VerifyEmailInput' }
12
+ responses:
13
+ "204": { description: email verified }
14
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
15
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
16
+ "429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
@@ -0,0 +1,77 @@
1
+ package user
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/user/model"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ type registerInput struct {
12
+ Email string `json:"email" binding:"required,email"`
13
+ Password string `json:"password" binding:"required,min=8"`
14
+ Name string `json:"name" binding:"required"`
15
+ }
16
+
17
+ type loginInput struct {
18
+ Email string `json:"email" binding:"required,email"`
19
+ Password string `json:"password" binding:"required"`
20
+ }
21
+
22
+ type forgotPasswordInput struct {
23
+ Email string `json:"email" binding:"required,email"`
24
+ }
25
+
26
+ type resetPasswordInput struct {
27
+ Token string `json:"token" binding:"required"`
28
+ NewPassword string `json:"new_password" binding:"required,min=8"`
29
+ }
30
+
31
+ type verifyEmailInput struct {
32
+ Token string `json:"token" binding:"required"`
33
+ }
34
+
35
+ // authResponse is what the service returns internally (both tokens); the
36
+ // handler sends the refresh token as an httpOnly cookie instead of echoing
37
+ // it in the body, so meResponse never carries it — see toCookieResponse.
38
+ type authResponse struct {
39
+ AccessToken string `json:"access_token"`
40
+ RefreshToken string `json:"-"`
41
+ TokenType string `json:"token_type"`
42
+ ExpiresIn int `json:"expires_in"`
43
+ }
44
+
45
+ type authCookieResponse struct {
46
+ AccessToken string `json:"access_token"`
47
+ TokenType string `json:"token_type"`
48
+ ExpiresIn int `json:"expires_in"`
49
+ }
50
+
51
+ func toCookieResponse(a *authResponse) authCookieResponse {
52
+ return authCookieResponse{AccessToken: a.AccessToken, TokenType: a.TokenType, ExpiresIn: a.ExpiresIn}
53
+ }
54
+
55
+ type meResponse struct {
56
+ ID uuid.UUID `json:"id"`
57
+ Email string `json:"email"`
58
+ Name string `json:"name"`
59
+ AvatarURL string `json:"avatar_url"`
60
+ EmailVerified bool `json:"email_verified"`
61
+ // go-scaffold:user-me-fields
62
+ CreatedAt time.Time `json:"created_at"`
63
+ }
64
+
65
+ func toMeResponse(u *model.User) meResponse {
66
+ return meResponse{
67
+ ID: u.ID,
68
+ Email: u.Email,
69
+ Name: u.Name,
70
+ AvatarURL: u.AvatarURL,
71
+ EmailVerified: u.EmailVerified,
72
+ CreatedAt: u.CreatedAt,
73
+ // go-scaffold:user-me-values
74
+ }
75
+ }
76
+
77
+ // go-scaffold:user-dto
@@ -0,0 +1,36 @@
1
+ package user
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/apperror"
7
+ )
8
+
9
+ // functions, not vars: the error middleware writes RequestID onto the
10
+ // returned pointer directly — a shared instance would race across
11
+ // concurrent requests.
12
+
13
+ func errNotFound() *apperror.AppError {
14
+ return apperror.New(http.StatusNotFound, "USER_NOT_FOUND", "user not found")
15
+ }
16
+
17
+ func errEmailTaken() *apperror.AppError {
18
+ return apperror.New(http.StatusConflict, "USER_EMAIL_TAKEN", "email already registered")
19
+ }
20
+
21
+ // errInvalidCredentials is returned for both "no such user" and "wrong
22
+ // password" — a single generic message so a login attempt can't be used to
23
+ // enumerate which emails have accounts.
24
+ func errInvalidCredentials() *apperror.AppError {
25
+ return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
26
+ }
27
+
28
+ func errInvalidToken() *apperror.AppError {
29
+ return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_TOKEN", "invalid or expired refresh token")
30
+ }
31
+
32
+ func errAlreadyVerified() *apperror.AppError {
33
+ return apperror.New(http.StatusConflict, "AUTH_ALREADY_VERIFIED", "email is already verified")
34
+ }
35
+
36
+ // go-scaffold:user-errors
@@ -0,0 +1,235 @@
1
+ package user
2
+
3
+ import (
4
+ "net/http"
5
+ "time"
6
+
7
+ "{{goModule}}/internal/shared/httpx"
8
+ "{{goModule}}/internal/shared/middleware"
9
+ // go-scaffold:user-handler-imports
10
+
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/google/uuid"
13
+ "github.com/redis/go-redis/v9"
14
+ )
15
+
16
+ const refreshCookieName = "refresh_token"
17
+
18
+ // go-scaffold:user-handler-consts
19
+
20
+ type Handler struct {
21
+ svc *Service
22
+ jwtSecret string
23
+ refreshTTL time.Duration
24
+ cookieSecure bool
25
+ rdb *redis.Client
26
+ // go-scaffold:user-handler-fields
27
+ }
28
+
29
+ func NewHandler(
30
+ svc *Service,
31
+ jwtSecret string,
32
+ refreshTTL time.Duration,
33
+ cookieSecure bool,
34
+ rdb *redis.Client,
35
+ // go-scaffold:user-handler-params
36
+ ) *Handler {
37
+ return &Handler{
38
+ svc: svc,
39
+ jwtSecret: jwtSecret,
40
+ refreshTTL: refreshTTL,
41
+ cookieSecure: cookieSecure,
42
+ rdb: rdb,
43
+ // go-scaffold:user-handler-init
44
+ }
45
+ }
46
+
47
+ // Register wires both a public /auth group (register/login/refresh/logout)
48
+ // and a /users group gated by RequireAuth — public vs protected is decided
49
+ // here, per domain, not centrally in main.go.
50
+ func (h *Handler) Register(rg gin.IRouter) {
51
+ // per-IP, per-route budgets — separate names so a burst on one endpoint
52
+ // doesn't spend another's budget. refresh/logout/google aren't limited:
53
+ // refresh/logout are gated by possessing a valid cookie already, and the
54
+ // 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)
61
+
62
+ authGroup := rg.Group("/auth")
63
+ authGroup.POST("/register", registerLimit, h.register)
64
+ authGroup.POST("/login", loginLimit, h.login)
65
+ authGroup.POST("/refresh", h.refresh)
66
+ authGroup.POST("/logout", h.logout)
67
+ authGroup.POST("/forgot-password", forgotPasswordLimit, h.forgotPassword)
68
+ authGroup.POST("/reset-password", resetPasswordLimit, h.resetPassword)
69
+ authGroup.POST("/verify-email", verifyEmailLimit, h.verifyEmail)
70
+ authGroup.GET("/google/login", h.googleLogin)
71
+ authGroup.GET("/google/callback", h.googleCallback)
72
+
73
+ usersGroup := rg.Group("/users", middleware.RequireAuth(h.jwtSecret))
74
+ usersGroup.GET("/me", h.me)
75
+ usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
76
+ usersGroup.POST("/me/logout-all", h.logoutAll)
77
+ // go-scaffold:user-routes
78
+ }
79
+
80
+ func (h *Handler) register(c *gin.Context) {
81
+ var in registerInput
82
+ if err := c.ShouldBindJSON(&in); err != nil {
83
+ c.Error(httpx.BindErr(err))
84
+ return
85
+ }
86
+ auth, err := h.svc.Register(c.Request.Context(), in)
87
+ if err != nil {
88
+ c.Error(err)
89
+ return
90
+ }
91
+ h.setRefreshCookie(c, auth.RefreshToken)
92
+ c.JSON(http.StatusCreated, toCookieResponse(auth))
93
+ }
94
+
95
+ func (h *Handler) login(c *gin.Context) {
96
+ var in loginInput
97
+ if err := c.ShouldBindJSON(&in); err != nil {
98
+ c.Error(httpx.BindErr(err))
99
+ return
100
+ }
101
+ auth, err := h.svc.Login(c.Request.Context(), in)
102
+ if err != nil {
103
+ c.Error(err)
104
+ return
105
+ }
106
+ h.setRefreshCookie(c, auth.RefreshToken)
107
+ c.JSON(http.StatusOK, toCookieResponse(auth))
108
+ }
109
+
110
+ func (h *Handler) refresh(c *gin.Context) {
111
+ raw, err := c.Cookie(refreshCookieName)
112
+ if err != nil || raw == "" {
113
+ c.Error(errInvalidToken())
114
+ return
115
+ }
116
+ auth, err := h.svc.Refresh(c.Request.Context(), raw)
117
+ if err != nil {
118
+ h.clearRefreshCookie(c)
119
+ c.Error(err)
120
+ return
121
+ }
122
+ h.setRefreshCookie(c, auth.RefreshToken)
123
+ c.JSON(http.StatusOK, toCookieResponse(auth))
124
+ }
125
+
126
+ func (h *Handler) logout(c *gin.Context) {
127
+ raw, _ := c.Cookie(refreshCookieName)
128
+ _ = h.svc.Logout(c.Request.Context(), raw)
129
+ h.clearRefreshCookie(c)
130
+ c.Status(http.StatusNoContent)
131
+ }
132
+
133
+ func (h *Handler) forgotPassword(c *gin.Context) {
134
+ var in forgotPasswordInput
135
+ if err := c.ShouldBindJSON(&in); err != nil {
136
+ c.Error(httpx.BindErr(err))
137
+ return
138
+ }
139
+ if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
140
+ c.Error(err)
141
+ return
142
+ }
143
+ // always the same response, whether or not the email exists — see
144
+ // Service.ForgotPassword for why.
145
+ c.JSON(http.StatusOK, gin.H{"message": "if that email exists, a reset link has been sent"})
146
+ }
147
+
148
+ func (h *Handler) resetPassword(c *gin.Context) {
149
+ var in resetPasswordInput
150
+ if err := c.ShouldBindJSON(&in); err != nil {
151
+ c.Error(httpx.BindErr(err))
152
+ return
153
+ }
154
+ if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
155
+ c.Error(err)
156
+ return
157
+ }
158
+ c.Status(http.StatusNoContent)
159
+ }
160
+
161
+ func (h *Handler) verifyEmail(c *gin.Context) {
162
+ var in verifyEmailInput
163
+ if err := c.ShouldBindJSON(&in); err != nil {
164
+ c.Error(httpx.BindErr(err))
165
+ return
166
+ }
167
+ if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
168
+ c.Error(err)
169
+ return
170
+ }
171
+ c.Status(http.StatusNoContent)
172
+ }
173
+
174
+ func (h *Handler) resendVerification(c *gin.Context) {
175
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
176
+ if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
177
+ c.Error(err)
178
+ return
179
+ }
180
+ c.Status(http.StatusNoContent)
181
+ }
182
+
183
+ // logoutAll ends every session for the caller, not just the one making the
184
+ // request — also clears this request's own cookie, since that session is
185
+ // dead too now.
186
+ func (h *Handler) logoutAll(c *gin.Context) {
187
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
188
+ if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
189
+ c.Error(err)
190
+ return
191
+ }
192
+ h.clearRefreshCookie(c)
193
+ c.Status(http.StatusNoContent)
194
+ }
195
+
196
+ func (h *Handler) googleLogin(c *gin.Context) {
197
+ url, err := h.svc.GoogleLoginURL()
198
+ if err != nil {
199
+ c.Error(err)
200
+ return
201
+ }
202
+ c.Redirect(http.StatusFound, url)
203
+ }
204
+
205
+ func (h *Handler) googleCallback(c *gin.Context) {
206
+ auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"))
207
+ if err != nil {
208
+ c.Error(err)
209
+ return
210
+ }
211
+ h.setRefreshCookie(c, auth.RefreshToken)
212
+ c.JSON(http.StatusOK, toCookieResponse(auth))
213
+ }
214
+
215
+ func (h *Handler) me(c *gin.Context) {
216
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
217
+ u, err := h.svc.Get(c.Request.Context(), userID)
218
+ if err != nil {
219
+ c.Error(err)
220
+ return
221
+ }
222
+ c.JSON(http.StatusOK, toMeResponse(u))
223
+ }
224
+
225
+ // go-scaffold:user-handler-funcs
226
+
227
+ func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
228
+ c.SetSameSite(http.SameSiteStrictMode)
229
+ c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
230
+ }
231
+
232
+ func (h *Handler) clearRefreshCookie(c *gin.Context) {
233
+ c.SetSameSite(http.SameSiteStrictMode)
234
+ c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
235
+ }